diff --git a/README.md b/README.md new file mode 100644 index 00000000..ff80abc8 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# slyguy.addons +mirror of https://k.slyguy.xyz/.repo/ diff --git a/plugin.audio.au.radio/.iptv_merge b/plugin.audio.au.radio/.iptv_merge new file mode 100644 index 00000000..0d34d629 --- /dev/null +++ b/plugin.audio.au.radio/.iptv_merge @@ -0,0 +1,4 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE" +} \ No newline at end of file diff --git a/plugin.audio.au.radio/__init__.py b/plugin.audio.au.radio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.audio.au.radio/addon.xml b/plugin.audio.au.radio/addon.xml new file mode 100644 index 00000000..5e8fb450 --- /dev/null +++ b/plugin.audio.au.radio/addon.xml @@ -0,0 +1,22 @@ + + + + + + + audio + + + Easily listen to all your favourite AU radio stations. +You can change the region (default = Sydney) in the addon settings. + true + + + + Add Bookmarks. Re-arrange menu + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.audio.au.radio/default.py b/plugin.audio.au.radio/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.audio.au.radio/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.audio.au.radio/fanart.jpg b/plugin.audio.au.radio/fanart.jpg new file mode 100644 index 00000000..77b967d4 Binary files /dev/null and b/plugin.audio.au.radio/fanart.jpg differ diff --git a/plugin.audio.au.radio/icon.png b/plugin.audio.au.radio/icon.png new file mode 100644 index 00000000..e772b7b4 Binary files /dev/null and b/plugin.audio.au.radio/icon.png differ diff --git a/plugin.audio.au.radio/resources/__init__.py b/plugin.audio.au.radio/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.audio.au.radio/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.audio.au.radio/resources/language/resource.language.en_gb/strings.po b/plugin.audio.au.radio/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..ec046882 --- /dev/null +++ b/plugin.audio.au.radio/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,68 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Region" +msgstr "" + +msgctxt "#30001" +msgid "Sydney" +msgstr "" + +msgctxt "#30002" +msgid "Melbourne" +msgstr "" + +msgctxt "#30003" +msgid "Brisbane" +msgstr "" + +msgctxt "#30004" +msgid "Perth" +msgstr "" + +msgctxt "#30005" +msgid "Adelaide" +msgstr "" + +msgctxt "#30006" +msgid "Darwin" +msgstr "" + +msgctxt "#30007" +msgid "Hobart" +msgstr "" + +msgctxt "#30008" +msgid "Canberra" +msgstr "" + +msgctxt "#30009" +msgid "Stations" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.audio.au.radio/resources/lib/__init__.py b/plugin.audio.au.radio/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.audio.au.radio/resources/lib/constants.py b/plugin.audio.au.radio/resources/lib/constants.py new file mode 100644 index 00000000..b627867b --- /dev/null +++ b/plugin.audio.au.radio/resources/lib/constants.py @@ -0,0 +1,2 @@ +REGIONS = ['Sydney', 'Melbourne', 'Brisbane', 'Perth', 'Adelaide', 'Darwin', 'Hobart', 'Canberra'] +DATA_URL = 'https://i.mjh.nz/au/{region}/radio.json.gz' \ No newline at end of file diff --git a/plugin.audio.au.radio/resources/lib/language.py b/plugin.audio.au.radio/resources/lib/language.py new file mode 100644 index 00000000..12b4ea6b --- /dev/null +++ b/plugin.audio.au.radio/resources/lib/language.py @@ -0,0 +1,17 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + REGION = 30000, + REGIONS = { + 'Sydney': 30001, + 'Melbourne': 30002, + 'Brisbane': 30003, + 'Perth': 30004, + 'Adelaide': 30005, + 'Darwin': 30006, + 'Hobart': 30007, + 'Canberra': 30008, + } + STATIONS = 30009 + +_ = Language() \ No newline at end of file diff --git a/plugin.audio.au.radio/resources/lib/plugin.py b/plugin.audio.au.radio/resources/lib/plugin.py new file mode 100644 index 00000000..d1463f60 --- /dev/null +++ b/plugin.audio.au.radio/resources/lib/plugin.py @@ -0,0 +1,85 @@ +import codecs + +from slyguy import plugin, settings +from slyguy.session import Session +from slyguy.mem_cache import cached + +from .constants import DATA_URL, REGIONS +from .language import _ + +session = Session() + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + folder.add_item(label=_(_.STATIONS, _bold=True), path=plugin.url_for(stations)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + + +@plugin.route() +def stations(**kwargs): + region = get_region() + folder = plugin.Folder(_(_.REGIONS[region])) + + channels = get_channels(region) + for slug in sorted(channels, key=lambda k: channels[k]['name']): + channel = channels[slug] + + folder.add_item( + label = channel['name'], + path = plugin.url_for(play, slug=slug, _is_live=True), + info = {'plot': channel.get('description')}, + video = channel.get('video', {}), + audio = channel.get('audio', {}), + art = {'thumb': channel.get('logo')}, + playable = True, + ) + + return folder + +@plugin.route() +def play(slug, **kwargs): + region = get_region() + channel = get_channels(region)[slug] + url = session.get(channel['mjh_master'], allow_redirects=False).headers.get('location', '') + + item = plugin.Item( + path = url, + headers = channel['headers'], + info = {'plot': channel.get('description')}, + video = channel.get('video', {}), + audio = channel.get('audio', {}), + art = {'thumb': channel.get('logo')}, + ) + + return item + +@cached(60*5) +def get_channels(region): + return session.gz_json(DATA_URL.format(region=region)) + +def get_region(): + return REGIONS[settings.getInt('region_index')] + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + region = get_region() + channels = get_channels(region) + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for slug in sorted(channels, key=lambda k: channels[k]['name']): + channel = channels[slug] + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{chno}" tvg-logo="{logo}" radio="true",{name}\n{path}\n'.format( + id=slug, logo=channel.get('logo', ''), name=channel['name'], chno=channel.get('channel', ''), + path=plugin.url_for(play, slug=slug, _is_live=True))) \ No newline at end of file diff --git a/plugin.audio.au.radio/resources/settings.xml b/plugin.audio.au.radio/resources/settings.xml new file mode 100644 index 00000000..bec18d16 --- /dev/null +++ b/plugin.audio.au.radio/resources/settings.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.audio.nz.radio/.iptv_merge b/plugin.audio.nz.radio/.iptv_merge new file mode 100644 index 00000000..d929be10 --- /dev/null +++ b/plugin.audio.nz.radio/.iptv_merge @@ -0,0 +1,4 @@ +{ + "version": 2, + "playlist": "plugin://$ID/?_=playlist&output=$FILE" +} \ No newline at end of file diff --git a/plugin.audio.nz.radio/__init__.py b/plugin.audio.nz.radio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.audio.nz.radio/addon.xml b/plugin.audio.nz.radio/addon.xml new file mode 100644 index 00000000..25da31bc --- /dev/null +++ b/plugin.audio.nz.radio/addon.xml @@ -0,0 +1,21 @@ + + + + + + + audio + + + Easily listen to all your favourite NZ radio stations + true + + + + Add Bookmarks. Re-arrange menu + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.audio.nz.radio/default.py b/plugin.audio.nz.radio/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.audio.nz.radio/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.audio.nz.radio/fanart.jpg b/plugin.audio.nz.radio/fanart.jpg new file mode 100644 index 00000000..930a4b05 Binary files /dev/null and b/plugin.audio.nz.radio/fanart.jpg differ diff --git a/plugin.audio.nz.radio/icon.png b/plugin.audio.nz.radio/icon.png new file mode 100644 index 00000000..8fd979a9 Binary files /dev/null and b/plugin.audio.nz.radio/icon.png differ diff --git a/plugin.audio.nz.radio/resources/__init__.py b/plugin.audio.nz.radio/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.audio.nz.radio/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.audio.nz.radio/resources/language/resource.language.en_gb/strings.po b/plugin.audio.nz.radio/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..d674d414 --- /dev/null +++ b/plugin.audio.nz.radio/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,32 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Stations" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.audio.nz.radio/resources/lib/__init__.py b/plugin.audio.nz.radio/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.audio.nz.radio/resources/lib/constants.py b/plugin.audio.nz.radio/resources/lib/constants.py new file mode 100644 index 00000000..efaeedb3 --- /dev/null +++ b/plugin.audio.nz.radio/resources/lib/constants.py @@ -0,0 +1 @@ +DATA_URL = 'https://i.mjh.nz/nz/radio.json.gz' \ No newline at end of file diff --git a/plugin.audio.nz.radio/resources/lib/language.py b/plugin.audio.nz.radio/resources/lib/language.py new file mode 100644 index 00000000..a6130fff --- /dev/null +++ b/plugin.audio.nz.radio/resources/lib/language.py @@ -0,0 +1,6 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + STATIONS = 30000 + +_ = Language() \ No newline at end of file diff --git a/plugin.audio.nz.radio/resources/lib/plugin.py b/plugin.audio.nz.radio/resources/lib/plugin.py new file mode 100644 index 00000000..ebc5fde2 --- /dev/null +++ b/plugin.audio.nz.radio/resources/lib/plugin.py @@ -0,0 +1,78 @@ +import codecs + +from slyguy import plugin, settings +from slyguy.mem_cache import cached +from slyguy.session import Session + +from .constants import DATA_URL +from .language import _ + +session = Session() + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + folder.add_item(label=_(_.STATIONS, _bold=True), path=plugin.url_for(stations)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def stations(**kwargs): + folder = plugin.Folder(_.STATIONS) + + channels = get_channels() + for slug in sorted(channels, key=lambda k: channels[k]['name']): + channel = channels[slug] + + folder.add_item( + label = channel['name'], + path = plugin.url_for(play, slug=slug, _is_live=True), + info = {'plot': channel.get('description')}, + video = channel.get('video', {}), + audio = channel.get('audio', {}), + art = {'thumb': channel.get('logo')}, + playable = True, + ) + + return folder + +@plugin.route() +def play(slug, **kwargs): + channel = get_channels()[slug] + url = session.get(channel['mjh_master'], allow_redirects=False).headers.get('location', '') + + item = plugin.Item( + path = url, + headers = channel['headers'], + info = {'plot': channel.get('description')}, + video = channel.get('video', {}), + audio = channel.get('audio', {}), + art = {'thumb': channel.get('logo')}, + ) + + return item + +@cached(60*5) +def get_channels(): + return session.gz_json(DATA_URL) + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + channels = get_channels() + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for slug in sorted(channels, key=lambda k: channels[k]['name']): + channel = channels[slug] + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{chno}" tvg-logo="{logo}" radio="true",{name}\n{path}\n'.format( + id=slug, logo=channel.get('logo', ''), name=channel['name'], chno=channel.get('channel', ''), + path=plugin.url_for(play, slug=slug, _is_live=True))) \ No newline at end of file diff --git a/plugin.audio.nz.radio/resources/settings.xml b/plugin.audio.nz.radio/resources/settings.xml new file mode 100644 index 00000000..9fbbad3a --- /dev/null +++ b/plugin.audio.nz.radio/resources/settings.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.program.gpio.monitor/__init__.py b/plugin.program.gpio.monitor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.program.gpio.monitor/addon.xml b/plugin.program.gpio.monitor/addon.xml new file mode 100644 index 00000000..49d3eac1 --- /dev/null +++ b/plugin.program.gpio.monitor/addon.xml @@ -0,0 +1,22 @@ + + + + + + + executable + + + + Monitors GPIO pins and runs KODI function(s) (eg. Shutdown) when it changes (Raspberry Pi Only) + true + + + + Fix breaking after update + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.program.gpio.monitor/default.py b/plugin.program.gpio.monitor/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.program.gpio.monitor/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.program.gpio.monitor/icon.png b/plugin.program.gpio.monitor/icon.png new file mode 100644 index 00000000..7d4c6ec4 Binary files /dev/null and b/plugin.program.gpio.monitor/icon.png differ diff --git a/plugin.program.gpio.monitor/resources/__init__.py b/plugin.program.gpio.monitor/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.program.gpio.monitor/resources/files/0.7.0_py2.so b/plugin.program.gpio.monitor/resources/files/0.7.0_py2.so new file mode 100644 index 00000000..e905fbb7 Binary files /dev/null and b/plugin.program.gpio.monitor/resources/files/0.7.0_py2.so differ diff --git a/plugin.program.gpio.monitor/resources/files/0.7.0_py3.so b/plugin.program.gpio.monitor/resources/files/0.7.0_py3.so new file mode 100644 index 00000000..4d232292 Binary files /dev/null and b/plugin.program.gpio.monitor/resources/files/0.7.0_py3.so differ diff --git a/plugin.program.gpio.monitor/resources/files/99-gpio.rules b/plugin.program.gpio.monitor/resources/files/99-gpio.rules new file mode 100644 index 00000000..436e33bf --- /dev/null +++ b/plugin.program.gpio.monitor/resources/files/99-gpio.rules @@ -0,0 +1,3 @@ +SUBSYSTEM=="bcm2835-gpiomem", KERNEL=="gpiomem", GROUP="gpio", MODE="0660" +SUBSYSTEM=="gpio", KERNEL=="gpiochip*", ACTION=="add", PROGRAM="/bin/sh -c 'chown root:gpio /sys/class/gpio/export /sys/class/gpio/unexport ; chmod 220 /sys/class/gpio/export /sys/class/gpio/unexport'" +SUBSYSTEM=="gpio", KERNEL=="gpio*", ACTION=="add", PROGRAM="/bin/sh -c 'chown root:gpio /sys%p/active_low /sys%p/direction /sys%p/edge /sys%p/value ; chmod 660 /sys%p/active_low /sys%p/direction /sys%p/edge /sys%p/value'" \ No newline at end of file diff --git a/plugin.program.gpio.monitor/resources/language/resource.language.en_gb/strings.po b/plugin.program.gpio.monitor/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..e8b71533 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,232 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Add Button" +msgstr "" + +msgctxt "#30001" +msgid "Reload Service" +msgstr "" + +msgctxt "#30002" +msgid "Error importing RPi.GPIO Library\n" +"Try installing service again." +msgstr "" + +msgctxt "#30003" +msgid "GPIO{pin}" +msgstr "" + +msgctxt "#30004" +msgid "Install Service" +msgstr "" + +msgctxt "#30005" +msgid "The GPIO service needs to be reloaded for any changes to take affect. Note: You can turn on auto-reload in the add-on settings." +msgstr "" + +msgctxt "#30006" +msgid "Select to install the GPIO Service (required)" +msgstr "" + +msgctxt "#30007" +msgid "Delete Button" +msgstr "" + +msgctxt "#30008" +msgid "Another button on this pin is already enabled.\n" +"Disable other button?" +msgstr "" + +msgctxt "#30009" +msgid "Are you sure you want to delete this button?" +msgstr "" + +msgctxt "#30010" +msgid "Auto Reload Service" +msgstr "" + +msgctxt "#30011" +msgid "{pin} {name} - {status}" +msgstr "" + +msgctxt "#30012" +msgid "Service Reloaded" +msgstr "" + +msgctxt "#30013" +msgid "Pin" +msgstr "" + +msgctxt "#30014" +msgid "Name" +msgstr "" + +msgctxt "#30015" +msgid "Enabled" +msgstr "" + +msgctxt "#30016" +msgid "Pull-Up" +msgstr "" + +msgctxt "#30017" +msgid "Bounce Time" +msgstr "" + +msgctxt "#30018" +msgid "Hold Time" +msgstr "" + +msgctxt "#30019" +msgid "Hold Repeat" +msgstr "" + +msgctxt "#30020" +msgid "When Pressed" +msgstr "" + +msgctxt "#30021" +msgid "When Released" +msgstr "" + +msgctxt "#30022" +msgid "When Held" +msgstr "" + +msgctxt "#30023" +msgid "{option}: {value}" +msgstr "" + +msgctxt "#30024" +msgid "Select the BCM Pin Number (2-26) for this button." +msgstr "" + +msgctxt "#30025" +msgid "(Optional) Enter a name for this button. This will be shown in it's label in the main menu." +msgstr "" + +msgctxt "#30026" +msgid "True: Button will be used by the service (default)\n\n" +"False: Button will be ignored by the service" +msgstr "" + +msgctxt "#30027" +msgid "True: Button is connected to GND (default)\n\n" +"False: Button is connected to 3.3V" +msgstr "" + +msgctxt "#30028" +msgid "The length of time (in seconds) that the button will ignore changes in state after an initial change." +msgstr "" + +msgctxt "#30029" +msgid "The length of time (in seconds) that the button must be held until the 'When Held' functions are executed." +msgstr "" + +msgctxt "#30030" +msgid "True: 'When Held' functions are executed repeatedly while button remains held.\n\n" +"False: Held functions are only executed once (default)." +msgstr "" + +msgctxt "#30031" +msgid "Enter the KODI built-in function(s) you want to run when the button is pressed. Multiple functions can be seperated with {delimiter}\n\n" +"Example: Action(Up)|Action(Left)" +msgstr "" + +msgctxt "#30032" +msgid "Enter the KODI built-in function(s) you want to run when the button is released. Multiple functions can be seperated with {delimiter}\n\n" +"Example: Action(Down)|Action(Right)" +msgstr "" + +msgctxt "#30033" +msgid "Enter the KODI built-in function(s) you want to run when the button is held. Multiple functions can be seperated with {delimiter}\n\n" +"Example: Action(Down)|Reboot" +msgstr "" + +msgctxt "#30034" +msgid "Add a new button" +msgstr "" + +msgctxt "#30035" +msgid "This system is not supported.\n" +"This add-on only supports LibreELEC, OSMC, XBian & Raspbian on the Raspberry Pi" +msgstr "" + +msgctxt "#30036" +msgid "GPIO Service not installed" +msgstr "" + +msgctxt "#30037" +msgid "Inactive" +msgstr "" + +msgctxt "#30038" +msgid "Active" +msgstr "" + +msgctxt "#30039" +msgid "Error" +msgstr "" + +msgctxt "#30040" +msgid "Disabled" +msgstr "" + +msgctxt "#30041" +msgid "Status" +msgstr "" + +msgctxt "#30042" +msgid "{error}" +msgstr "" + +msgctxt "#30043" +msgid "Service Installed\n" +"A restart is required. Restart now?" +msgstr "" + +msgctxt "#30044" +msgid "Please enter the password for 'xbian' user" +msgstr "" + +msgctxt "#30045" +msgid "Unable to run the install command.\n" +"Please check the 'xbian' user password is correct." +msgstr "" + +msgctxt "#30046" +msgid "Button is actively being monitored by the GPIO Service" +msgstr "" + +msgctxt "#30047" +msgid "Button is not being monitored by the GPIO Service" +msgstr "" + +msgctxt "#30048" +msgid "Button has been disabled" +msgstr "" + +msgctxt "#30049" +msgid "The below error occured\n" +"{error}" +msgstr "" + +msgctxt "#30050" +msgid "Test Press" +msgstr "" + +msgctxt "#30051" +msgid "Test Release" +msgstr "" + +msgctxt "#30052" +msgid "Test Hold" +msgstr "" + +msgctxt "#30053" +msgid "Simulation" +msgstr "" + +## COMMON SETTINGS ## diff --git a/plugin.program.gpio.monitor/resources/lib/RPi/GPIO/__init__.py b/plugin.program.gpio.monitor/resources/lib/RPi/GPIO/__init__.py new file mode 100644 index 00000000..6b7adad3 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/RPi/GPIO/__init__.py @@ -0,0 +1,25 @@ +""" +Copyright (c) 2012-2016 Ben Croston + +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. +""" + +from RPi._GPIO import * + +VERSION = '0.6.2' diff --git a/plugin.program.gpio.monitor/resources/lib/RPi/__init__.py b/plugin.program.gpio.monitor/resources/lib/RPi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.program.gpio.monitor/resources/lib/__init__.py b/plugin.program.gpio.monitor/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.program.gpio.monitor/resources/lib/constants.py b/plugin.program.gpio.monitor/resources/lib/constants.py new file mode 100644 index 00000000..e581b5e2 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/constants.py @@ -0,0 +1,13 @@ +INSTALLED_KEY = 'gpio.installed' + +BCM_PINS = [ + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27 +] + +COLOR_INACTIVE = 'FFA9A9A9' +COLOR_ACTIVE = 'FF19f109' +COLOR_DISABLED = 'FFE59400' +COLOR_ERROR = 'FFFF0000' + +FUNCTION_DELIMETER = '|' +AUTO_RELOAD_SETTING = 'auto_reload' \ No newline at end of file diff --git a/plugin.program.gpio.monitor/resources/lib/gpio.py b/plugin.program.gpio.monitor/resources/lib/gpio.py new file mode 100644 index 00000000..2ad78ece --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpio.py @@ -0,0 +1,181 @@ +import os +import sys +import subprocess +import shutil + +from six import PY3 +from kodi_six import xbmc + +from slyguy import gui, database, userdata +from slyguy.constants import ADDON_PATH, ADDON_ID, ADDON_DEV +from slyguy.log import log +from slyguy.exceptions import Error +from slyguy.util import get_system_arch, set_kodi_string, get_kodi_string, remove_file, md5sum + +from .constants import * +from .language import _ +from .models import Button + +if PY3: + # http://archive.raspberrypi.org/debian/pool/main/r/rpi.gpio/python3-rpi.gpio_0.7.0~buster-1_armhf.deb + SO_SRC = os.path.join(ADDON_PATH, 'resources', 'files', '0.7.0_py3.so') +else: + # http://archive.raspberrypi.org/debian/pool/main/r/rpi.gpio/python-rpi.gpio_0.7.0~buster-1_armhf.deb + SO_SRC = os.path.join(ADDON_PATH, 'resources', 'files', '0.7.0_py2.so') + +SO_DST = os.path.join(ADDON_PATH, 'resources', 'lib', 'RPi', '_GPIO.so') + +if not os.path.exists(SO_SRC): + raise Exception('Missing required {} file'.format(SO_SRC)) + +if md5sum(SO_SRC) != md5sum(SO_DST): + remove_file(SO_DST) + shutil.copy(SO_SRC, SO_DST) + +if os.path.exists('/storage/.kodi'): + SYSTEM = 'libreelec' +elif os.path.exists('/home/osmc'): + SYSTEM = 'osmc' +elif os.path.exists('/home/pi'): + SYSTEM = 'raspbian' +elif os.path.exists('/home/xbian'): + SYSTEM = 'xbian' +else: + SYSTEM = 'mock' + +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +import gpiozero + +INSTALLED = False +if SYSTEM == 'mock': + from gpiozero.pins.mock import MockFactory as Factory + gpiozero.Device.pin_factory = Factory() + log.debug('System not supported. Using mock factory') + INSTALLED = True +else: + try: + from gpiozero.pins.rpigpio import RPiGPIOFactory as Factory + gpiozero.Device.pin_factory = Factory() + gpiozero.Device.pin_factory.pin(BCM_PINS[0]).state + INSTALLED = True + except Exception as e: + log.exception(e) + +def install(): + remove_file(SO_DST) + shutil.copy(SO_SRC, SO_DST) + + if SYSTEM == 'libreelec': + install_libreelec() + elif SYSTEM == 'raspbian': + install_raspbian() + elif SYSTEM == 'osmc': + install_osmc() + return True + elif SYSTEM == 'xbian': + install_xbian() + return True + elif SYSTEM == 'mock': + gui.ok(_.SYSTEM_UNSUPPORTED) + +def install_libreelec(): + return + +def install_raspbian(): + return + +def install_osmc(): + sudo_cmd = 'sudo su -c "{}"' + install_debian(sudo_cmd, 'osmc') + +def install_xbian(): + password = gui.input(_.XBIAN_PASSWORD, default='raspberry') + sudo_cmd = 'echo "{}" | sudo -S su -c "{{}}"'.format(password) + + try: + install_debian(sudo_cmd, 'xbian') + except Exception as e: + log.exception(e) + raise Error(_.XBIAN_ERROR) + +def install_debian(sudo_cmd, user): + def cmd(cmd): + return subprocess.check_output(sudo_cmd.format(cmd), shell=True).strip() + + src_path = os.path.join(ADDON_PATH, 'resources', 'files', '99-gpio.rules') + dst_path = '/etc/udev/rules.d/99-{}.GPIO.rules'.format(ADDON_ID) + cmd('groupadd -f -r gpio && adduser {0} gpio && adduser root gpio && rm -f "{2}" && cp "{1}" "{2}"'.format(user, src_path, dst_path)) + +def set_state(pin, state): + if not INSTALLED: + return + + log.debug('Set pin {} to {}'.format(pin, state)) + out_pin = gpiozero.Device.pin_factory.pin(int(pin)) + out_pin.output_with_state(int(state)) + +def callback(function): + log.debug('Callback: {}'.format(function)) + + for function in function.split(FUNCTION_DELIMETER): + xbmc.executebuiltin(function.strip()) + +def service(): + def setup_buttons(): + log.debug('Setting up buttons') + + try: + database.connect() + + Button.update(status=Button.Status.INACTIVE, error=None).where(Button.enabled == True).execute() + Button.update(status=Button.Status.DISABLED, error=None).where(Button.enabled == False).execute() + btns = list(Button.select().where(Button.enabled == True)) + + buttons = [] + for btn in btns: + if not btn.has_callbacks(): + continue + + try: + button = gpiozero.Button(btn.pin, pull_up=btn.pull_up, + bounce_time=btn.bounce_time or None, hold_time=btn.hold_time, hold_repeat=btn.hold_repeat) + + if btn.when_pressed: + button.when_pressed = lambda function=btn.when_pressed: callback(function) + + if btn.when_released: + button.when_released = lambda function=btn.when_released: callback(function) + + if btn.when_held: + button.when_held = lambda function=btn.when_held: callback(function) + except Exception as e: + log.exception(e) + btn.status = Button.Status.ERROR + btn.error = e + else: + btn.status = Button.Status.ACTIVE + buttons.append(button) + + btn.save() + + return buttons + except Exception as e: + log.debug(e) + return [] + finally: + database.close() + + monitor = xbmc.Monitor() + + while not monitor.abortRequested(): + buttons = setup_buttons() + + set_kodi_string('_gpio_reload') + while not monitor.abortRequested(): + if not monitor.waitForAbort(1) and get_kodi_string('_gpio_reload'): + break + + for button in buttons: + button.close() + + gpiozero.Device.pin_factory.close() \ No newline at end of file diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/__init__.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/__init__.py new file mode 100644 index 00000000..e7acaa19 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/__init__.py @@ -0,0 +1,102 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, +) + +from .pins import ( + Factory, + Pin, + SPI, +) +from .pins.data import ( + PiBoardInfo, + HeaderInfo, + PinInfo, + pi_info, +) +# Yes, import * is naughty, but exc imports nothing else so there's no cross +# contamination here ... and besides, have you *seen* the list lately?! +from .exc import * +from .devices import ( + Device, + GPIODevice, + CompositeDevice, +) +from .mixins import ( + SharedMixin, + SourceMixin, + ValuesMixin, + EventsMixin, + HoldMixin, +) +from .input_devices import ( + InputDevice, + DigitalInputDevice, + SmoothedInputDevice, + Button, + LineSensor, + MotionSensor, + LightSensor, + DistanceSensor, +) +from .spi_devices import ( + SPIDevice, + AnalogInputDevice, + MCP3001, + MCP3002, + MCP3004, + MCP3008, + MCP3201, + MCP3202, + MCP3204, + MCP3208, + MCP3301, + MCP3302, + MCP3304, +) +from .output_devices import ( + OutputDevice, + DigitalOutputDevice, + PWMOutputDevice, + PWMLED, + LED, + Buzzer, + Motor, + PhaseEnableMotor, + Servo, + AngularServo, + RGBLED, +) +from .boards import ( + CompositeOutputDevice, + ButtonBoard, + LEDCollection, + LEDBoard, + LEDBarGraph, + LedBorg, + PiLiter, + PiLiterBarGraph, + TrafficLights, + PiTraffic, + PiStop, + StatusZero, + StatusBoard, + SnowPi, + TrafficLightsBuzzer, + FishDish, + TrafficHat, + Robot, + RyanteckRobot, + CamJamKitRobot, + PhaseEnableRobot, + PololuDRV8835Robot, + Energenie, +) +from .other_devices import ( + InternalDevice, + PingServer, + CPUTemperature, + TimeOfDay, +) diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/boards.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/boards.py new file mode 100644 index 00000000..e4a4ddf2 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/boards.py @@ -0,0 +1,1619 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, + ) +try: + from itertools import izip as zip +except ImportError: + pass + +from time import sleep +from itertools import repeat, cycle, chain +from threading import Lock +from collections import OrderedDict, Counter + +from .exc import ( + DeviceClosed, + GPIOPinMissing, + EnergenieSocketMissing, + EnergenieBadSocket, + OutputDeviceBadValue, + ) +from .input_devices import Button +from .output_devices import ( + OutputDevice, + LED, + PWMLED, + RGBLED, + Buzzer, + Motor, + PhaseEnableMotor, + ) +from .threads import GPIOThread +from .devices import Device, CompositeDevice +from .mixins import SharedMixin, SourceMixin, HoldMixin + + +class CompositeOutputDevice(SourceMixin, CompositeDevice): + """ + Extends :class:`CompositeDevice` with :meth:`on`, :meth:`off`, and + :meth:`toggle` methods for controlling subordinate output devices. Also + extends :attr:`value` to be writeable. + + :param list _order: + If specified, this is the order of named items specified by keyword + arguments (to ensure that the :attr:`value` tuple is constructed with a + specific order). All keyword arguments *must* be included in the + collection. If omitted, an alphabetically sorted order will be selected + for keyword arguments. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + + def on(self): + """ + Turn all the output devices on. + """ + for device in self: + if isinstance(device, (OutputDevice, CompositeOutputDevice)): + device.on() + + def off(self): + """ + Turn all the output devices off. + """ + for device in self: + if isinstance(device, (OutputDevice, CompositeOutputDevice)): + device.off() + + def toggle(self): + """ + Toggle all the output devices. For each device, if it's on, turn it + off; if it's off, turn it on. + """ + for device in self: + if isinstance(device, (OutputDevice, CompositeOutputDevice)): + device.toggle() + + @property + def value(self): + """ + A tuple containing a value for each subordinate device. This property + can also be set to update the state of all subordinate output devices. + """ + return super(CompositeOutputDevice, self).value + + @value.setter + def value(self, value): + for device, v in zip(self, value): + if isinstance(device, (OutputDevice, CompositeOutputDevice)): + device.value = v + # Simply ignore values for non-output devices + + +class ButtonBoard(HoldMixin, CompositeDevice): + """ + Extends :class:`CompositeDevice` and represents a generic button board or + collection of buttons. + + :param int \*pins: + Specify the GPIO pins that the buttons of the board are attached to. + You can designate as many pins as necessary. + + :param bool pull_up: + If ``True`` (the default), the GPIO pins will be pulled high by + default. In this case, connect the other side of the buttons to + ground. If ``False``, the GPIO pins will be pulled low by default. In + this case, connect the other side of the buttons to 3V3. This + parameter can only be specified as a keyword parameter. + + :param float bounce_time: + If ``None`` (the default), no software bounce compensation will be + performed. Otherwise, this is the length of time (in seconds) that the + buttons will ignore changes in state after an initial change. This + parameter can only be specified as a keyword parameter. + + :param float hold_time: + The length of time (in seconds) to wait after any button is pushed, + until executing the :attr:`when_held` handler. Defaults to ``1``. This + parameter can only be specified as a keyword parameter. + + :param bool hold_repeat: + If ``True``, the :attr:`when_held` handler will be repeatedly executed + as long as any buttons remain held, every *hold_time* seconds. If + ``False`` (the default) the :attr:`when_held` handler will be only be + executed once per hold. This parameter can only be specified as a + keyword parameter. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + :param \*\*named_pins: + Specify GPIO pins that buttons of the board are attached to, + associating each button with a property name. You can designate as + many pins as necessary and use any names, provided they're not already + in use by something else. + """ + def __init__(self, *args, **kwargs): + pull_up = kwargs.pop('pull_up', True) + bounce_time = kwargs.pop('bounce_time', None) + hold_time = kwargs.pop('hold_time', 1) + hold_repeat = kwargs.pop('hold_repeat', False) + pin_factory = kwargs.pop('pin_factory', None) + order = kwargs.pop('_order', None) + super(ButtonBoard, self).__init__( + *( + Button(pin, pull_up, bounce_time, hold_time, hold_repeat) + for pin in args + ), + _order=order, + pin_factory=pin_factory, + **{ + name: Button(pin, pull_up, bounce_time, hold_time, hold_repeat) + for name, pin in kwargs.items() + }) + def get_new_handler(device): + def fire_both_events(): + device._fire_events() + self._fire_events() + return fire_both_events + for button in self: + button.pin.when_changed = get_new_handler(button) + self._when_changed = None + self._last_value = None + # Call _fire_events once to set initial state of events + self._fire_events() + self.hold_time = hold_time + self.hold_repeat = hold_repeat + + @property + def pull_up(self): + """ + If ``True``, the device uses a pull-up resistor to set the GPIO pin + "high" by default. + """ + return self[0].pull_up + + @property + def when_changed(self): + return self._when_changed + + @when_changed.setter + def when_changed(self, value): + self._when_changed = self._wrap_callback(value) + + def _fire_changed(self): + if self.when_changed: + self.when_changed() + + def _fire_events(self): + super(ButtonBoard, self)._fire_events() + old_value = self._last_value + new_value = self._last_value = self.value + if old_value is None: + # Initial "indeterminate" value; don't do anything + pass + elif old_value != new_value: + self._fire_changed() + + +ButtonBoard.is_pressed = ButtonBoard.is_active +ButtonBoard.pressed_time = ButtonBoard.active_time +ButtonBoard.when_pressed = ButtonBoard.when_activated +ButtonBoard.when_released = ButtonBoard.when_deactivated +ButtonBoard.wait_for_press = ButtonBoard.wait_for_active +ButtonBoard.wait_for_release = ButtonBoard.wait_for_inactive + + +class LEDCollection(CompositeOutputDevice): + """ + Extends :class:`CompositeOutputDevice`. Abstract base class for + :class:`LEDBoard` and :class:`LEDBarGraph`. + """ + def __init__(self, *args, **kwargs): + pwm = kwargs.pop('pwm', False) + active_high = kwargs.pop('active_high', True) + initial_value = kwargs.pop('initial_value', False) + pin_factory = kwargs.pop('pin_factory', None) + order = kwargs.pop('_order', None) + LEDClass = PWMLED if pwm else LED + super(LEDCollection, self).__init__( + *( + pin_or_collection + if isinstance(pin_or_collection, LEDCollection) else + LEDClass( + pin_or_collection, active_high, initial_value, + pin_factory=pin_factory + ) + for pin_or_collection in args + ), + _order=order, + pin_factory=pin_factory, + **{ + name: pin_or_collection + if isinstance(pin_or_collection, LEDCollection) else + LEDClass( + pin_or_collection, active_high, initial_value, + pin_factory=pin_factory + ) + for name, pin_or_collection in kwargs.items() + }) + leds = [] + for item in self: + if isinstance(item, LEDCollection): + for subitem in item.leds: + leds.append(subitem) + else: + leds.append(item) + self._leds = tuple(leds) + + @property + def leds(self): + """ + A flat tuple of all LEDs contained in this collection (and all + sub-collections). + """ + return self._leds + + @property + def active_high(self): + return self[0].active_high + + +class LEDBoard(LEDCollection): + """ + Extends :class:`LEDCollection` and represents a generic LED board or + collection of LEDs. + + The following example turns on all the LEDs on a board containing 5 LEDs + attached to GPIO pins 2 through 6:: + + from gpiozero import LEDBoard + + leds = LEDBoard(2, 3, 4, 5, 6) + leds.on() + + :param int \*pins: + Specify the GPIO pins that the LEDs of the board are attached to. You + can designate as many pins as necessary. You can also specify + :class:`LEDBoard` instances to create trees of LEDs. + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances for each pin. If + ``False`` (the default), construct regular :class:`LED` instances. This + parameter can only be specified as a keyword parameter. + + :param bool active_high: + If ``True`` (the default), the :meth:`on` method will set all the + associated pins to HIGH. If ``False``, the :meth:`on` method will set + all pins to LOW (the :meth:`off` method always does the opposite). This + parameter can only be specified as a keyword parameter. + + :param bool initial_value: + If ``False`` (the default), all LEDs will be off initially. If + ``None``, each device will be left in whatever state the pin is found + in when configured for output (warning: this can be on). If ``True``, + the device will be switched on initially. This parameter can only be + specified as a keyword parameter. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + :param \*\*named_pins: + Specify GPIO pins that LEDs of the board are attached to, associating + each LED with a property name. You can designate as many pins as + necessary and use any names, provided they're not already in use by + something else. You can also specify :class:`LEDBoard` instances to + create trees of LEDs. + """ + def __init__(self, *args, **kwargs): + self._blink_thread = None + self._blink_leds = [] + self._blink_lock = Lock() + super(LEDBoard, self).__init__(*args, **kwargs) + + def close(self): + try: + self._stop_blink() + except AttributeError: + pass + super(LEDBoard, self).close() + + def on(self, *args): + self._stop_blink() + if args: + for index in args: + self[index].on() + else: + super(LEDBoard, self).on() + + def off(self, *args): + self._stop_blink() + if args: + for index in args: + self[index].off() + else: + super(LEDBoard, self).off() + + def toggle(self, *args): + self._stop_blink() + if args: + for index in args: + self[index].toggle() + else: + super(LEDBoard, self).toggle() + + def blink( + self, on_time=1, off_time=1, fade_in_time=0, fade_out_time=0, + n=None, background=True): + """ + Make all the LEDs turn on and off repeatedly. + + :param float on_time: + Number of seconds on. Defaults to 1 second. + + :param float off_time: + Number of seconds off. Defaults to 1 second. + + :param float fade_in_time: + Number of seconds to spend fading in. Defaults to 0. Must be 0 if + ``pwm`` was ``False`` when the class was constructed + (:exc:`ValueError` will be raised if not). + + :param float fade_out_time: + Number of seconds to spend fading out. Defaults to 0. Must be 0 if + ``pwm`` was ``False`` when the class was constructed + (:exc:`ValueError` will be raised if not). + + :param int n: + Number of times to blink; ``None`` (the default) means forever. + + :param bool background: + If ``True``, start a background thread to continue blinking and + return immediately. If ``False``, only return when the blink is + finished (warning: the default value of *n* will result in this + method never returning). + """ + for led in self.leds: + if isinstance(led, LED): + if fade_in_time: + raise ValueError('fade_in_time must be 0 with non-PWM LEDs') + if fade_out_time: + raise ValueError('fade_out_time must be 0 with non-PWM LEDs') + self._stop_blink() + self._blink_thread = GPIOThread( + target=self._blink_device, + args=(on_time, off_time, fade_in_time, fade_out_time, n) + ) + self._blink_thread.start() + if not background: + self._blink_thread.join() + self._blink_thread = None + + def _stop_blink(self, led=None): + if led is None: + if self._blink_thread: + self._blink_thread.stop() + self._blink_thread = None + else: + with self._blink_lock: + self._blink_leds.remove(led) + + def pulse(self, fade_in_time=1, fade_out_time=1, n=None, background=True): + """ + Make the device fade in and out repeatedly. + + :param float fade_in_time: + Number of seconds to spend fading in. Defaults to 1. + + :param float fade_out_time: + Number of seconds to spend fading out. Defaults to 1. + + :param int n: + Number of times to blink; ``None`` (the default) means forever. + + :param bool background: + If ``True`` (the default), start a background thread to continue + blinking and return immediately. If ``False``, only return when the + blink is finished (warning: the default value of *n* will result in + this method never returning). + """ + on_time = off_time = 0 + self.blink( + on_time, off_time, fade_in_time, fade_out_time, n, background + ) + + def _blink_device(self, on_time, off_time, fade_in_time, fade_out_time, n, fps=25): + sequence = [] + if fade_in_time > 0: + sequence += [ + (i * (1 / fps) / fade_in_time, 1 / fps) + for i in range(int(fps * fade_in_time)) + ] + sequence.append((1, on_time)) + if fade_out_time > 0: + sequence += [ + (1 - (i * (1 / fps) / fade_out_time), 1 / fps) + for i in range(int(fps * fade_out_time)) + ] + sequence.append((0, off_time)) + sequence = ( + cycle(sequence) if n is None else + chain.from_iterable(repeat(sequence, n)) + ) + with self._blink_lock: + self._blink_leds = list(self.leds) + for led in self._blink_leds: + if led._controller not in (None, self): + led._controller._stop_blink(led) + led._controller = self + for value, delay in sequence: + with self._blink_lock: + if not self._blink_leds: + break + for led in self._blink_leds: + led._write(value) + if self._blink_thread.stopping.wait(delay): + break + + +class LEDBarGraph(LEDCollection): + """ + Extends :class:`LEDCollection` to control a line of LEDs representing a + bar graph. Positive values (0 to 1) light the LEDs from first to last. + Negative values (-1 to 0) light the LEDs from last to first. + + The following example demonstrates turning on the first two and last two + LEDs in a board containing five LEDs attached to GPIOs 2 through 6:: + + from gpiozero import LEDBarGraph + from time import sleep + + graph = LEDBarGraph(2, 3, 4, 5, 6) + graph.value = 2/5 # Light the first two LEDs only + sleep(1) + graph.value = -2/5 # Light the last two LEDs only + sleep(1) + graph.off() + + As with other output devices, :attr:`source` and :attr:`values` are + supported:: + + from gpiozero import LEDBarGraph, MCP3008 + from signal import pause + + graph = LEDBarGraph(2, 3, 4, 5, 6, pwm=True) + pot = MCP3008(channel=0) + graph.source = pot.values + pause() + + :param int \*pins: + Specify the GPIO pins that the LEDs of the bar graph are attached to. + You can designate as many pins as necessary. + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances for each pin. If + ``False`` (the default), construct regular :class:`LED` instances. This + parameter can only be specified as a keyword parameter. + + :param bool active_high: + If ``True`` (the default), the :meth:`on` method will set all the + associated pins to HIGH. If ``False``, the :meth:`on` method will set + all pins to LOW (the :meth:`off` method always does the opposite). This + parameter can only be specified as a keyword parameter. + + :param float initial_value: + The initial :attr:`value` of the graph given as a float between -1 and + +1. Defaults to ``0.0``. This parameter can only be specified as a + keyword parameter. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + + def __init__(self, *pins, **kwargs): + # Don't allow graphs to contain collections + for pin in pins: + assert not isinstance(pin, LEDCollection) + pwm = kwargs.pop('pwm', False) + active_high = kwargs.pop('active_high', True) + initial_value = kwargs.pop('initial_value', 0.0) + pin_factory = kwargs.pop('pin_factory', None) + if kwargs: + raise TypeError('unexpected keyword argument: %s' % kwargs.popitem()[0]) + super(LEDBarGraph, self).__init__( + *pins, pwm=pwm, active_high=active_high, pin_factory=pin_factory + ) + try: + self.value = initial_value + except: + self.close() + raise + + @property + def value(self): + """ + The value of the LED bar graph. When no LEDs are lit, the value is 0. + When all LEDs are lit, the value is 1. Values between 0 and 1 + light LEDs linearly from first to last. Values between 0 and -1 + light LEDs linearly from last to first. + + To light a particular number of LEDs, simply divide that number by + the number of LEDs. For example, if your graph contains 3 LEDs, the + following will light the first:: + + from gpiozero import LEDBarGraph + + graph = LEDBarGraph(12, 16, 19) + graph.value = 1/3 + + .. note:: + + Setting value to -1 will light all LEDs. However, querying it + subsequently will return 1 as both representations are the same in + hardware. The readable range of :attr:`value` is effectively + -1 < value <= 1. + """ + result = sum(led.value for led in self) + if self[0].value < self[-1].value: + result = -result + return result / len(self) + + @value.setter + def value(self, value): + if not -1 <= value <= 1: + raise OutputDeviceBadValue('LEDBarGraph value must be between -1 and 1') + count = len(self) + leds = self + if value < 0: + leds = reversed(leds) + value = -value + if isinstance(self[0], PWMLED): + calc_value = lambda index: min(1, max(0, count * value - index)) + else: + calc_value = lambda index: value >= ((index + 1) / count) + for index, led in enumerate(leds): + led.value = calc_value(index) + + @property + def lit_count(self): + """ + The number of LEDs on the bar graph actually lit up. Note that just + like ``value``, this can be negative if the LEDs are lit from last to + first. + """ + lit_value = self.value * len(self) + if not isinstance(self[0], PWMLED): + lit_value = int(lit_value) + return lit_value + + @lit_count.setter + def lit_count(self, value): + self.value = value / len(self) + + +class LedBorg(RGBLED): + """ + Extends :class:`RGBLED` for the `PiBorg LedBorg`_: an add-on board + containing a very bright RGB LED. + + The LedBorg pins are fixed and therefore there's no need to specify them + when constructing this class. The following example turns the LedBorg + purple:: + + from gpiozero import LedBorg + + led = LedBorg() + led.color = (1, 0, 1) + + :param tuple initial_value: + The initial color for the LedBorg. Defaults to black ``(0, 0, 0)``. + + :param bool pwm: + If ``True`` (the default), construct :class:`PWMLED` instances for + each component of the LedBorg. If ``False``, construct regular + :class:`LED` instances, which prevents smooth color graduations. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _PiBorg LedBorg: https://www.piborg.org/ledborg + """ + + def __init__(self, initial_value=(0, 0, 0), pwm=True, pin_factory=None): + super(LedBorg, self).__init__(red=17, green=27, blue=22, + pwm=pwm, initial_value=initial_value, + pin_factory=pin_factory) + + +class PiLiter(LEDBoard): + """ + Extends :class:`LEDBoard` for the `Ciseco Pi-LITEr`_: a strip of 8 very bright + LEDs. + + The Pi-LITEr pins are fixed and therefore there's no need to specify them + when constructing this class. The following example turns on all the LEDs + of the Pi-LITEr:: + + from gpiozero import PiLiter + + lite = PiLiter() + lite.on() + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances for each pin. If + ``False`` (the default), construct regular :class:`LED` instances. + + :param bool initial_value: + If ``False`` (the default), all LEDs will be off initially. If + ``None``, each device will be left in whatever state the pin is found + in when configured for output (warning: this can be on). If ``True``, + the device will be switched on initially. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Ciseco Pi-LITEr: http://shop.ciseco.co.uk/pi-liter-8-led-strip-for-the-raspberry-pi/ + """ + + def __init__(self, pwm=False, initial_value=False, pin_factory=None): + super(PiLiter, self).__init__(4, 17, 27, 18, 22, 23, 24, 25, + pwm=pwm, initial_value=initial_value, + pin_factory=pin_factory) + + +class PiLiterBarGraph(LEDBarGraph): + """ + Extends :class:`LEDBarGraph` to treat the `Ciseco Pi-LITEr`_ as an + 8-segment bar graph. + + The Pi-LITEr pins are fixed and therefore there's no need to specify them + when constructing this class. The following example sets the graph value + to 0.5:: + + from gpiozero import PiLiterBarGraph + + graph = PiLiterBarGraph() + graph.value = 0.5 + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances for each pin. If + ``False`` (the default), construct regular :class:`LED` instances. + + :param float initial_value: + The initial :attr:`value` of the graph given as a float between -1 and + +1. Defaults to ``0.0``. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Ciseco Pi-LITEr: http://shop.ciseco.co.uk/pi-liter-8-led-strip-for-the-raspberry-pi/ + """ + + def __init__(self, pwm=False, initial_value=0.0, pin_factory=None): + pins = (4, 17, 27, 18, 22, 23, 24, 25) + super(PiLiterBarGraph, self).__init__( + *pins, pwm=pwm, initial_value=initial_value, pin_factory=pin_factory + ) + + +class TrafficLights(LEDBoard): + """ + Extends :class:`LEDBoard` for devices containing red, yellow, and green + LEDs. + + The following example initializes a device connected to GPIO pins 2, 3, + and 4, then lights the amber (yellow) LED attached to GPIO 3:: + + from gpiozero import TrafficLights + + traffic = TrafficLights(2, 3, 4) + traffic.amber.on() + + :param int red: + The GPIO pin that the red LED is attached to. + + :param int amber: + The GPIO pin that the amber LED is attached to. + + :param int green: + The GPIO pin that the green LED is attached to. + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances to represent each + LED. If ``False`` (the default), construct regular :class:`LED` + instances. + + :param bool initial_value: + If ``False`` (the default), all LEDs will be off initially. If + ``None``, each device will be left in whatever state the pin is found + in when configured for output (warning: this can be on). If ``True``, + the device will be switched on initially. + + :param int yellow: + The GPIO pin that the yellow LED is attached to. This is merely an + alias for the ``amber`` parameter - you can't specify both ``amber`` + and ``yellow``. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__(self, red=None, amber=None, green=None, + pwm=False, initial_value=False, yellow=None, + pin_factory=None): + if amber is not None and yellow is not None: + raise OutputDeviceBadValue( + 'Only one of amber or yellow can be specified' + ) + devices = OrderedDict((('red', red), )) + self._display_yellow = amber is None and yellow is not None + if self._display_yellow: + devices['yellow'] = yellow + else: + devices['amber'] = amber + devices['green'] = green + if not all(p is not None for p in devices.values()): + raise GPIOPinMissing( + ', '.join(devices.keys())+' pins must be provided' + ) + super(TrafficLights, self).__init__( + pwm=pwm, initial_value=initial_value, + _order=devices.keys(), pin_factory=pin_factory, + **devices) + + def __getattr__(self, name): + if name == 'amber' and self._display_yellow: + name = 'yellow' + elif name == 'yellow' and not self._display_yellow: + name = 'amber' + return super(TrafficLights, self).__getattr__(name) + + +class PiTraffic(TrafficLights): + """ + Extends :class:`TrafficLights` for the `Low Voltage Labs PI-TRAFFIC`_ + vertical traffic lights board when attached to GPIO pins 9, 10, and 11. + + There's no need to specify the pins if the PI-TRAFFIC is connected to the + default pins (9, 10, 11). The following example turns on the amber LED on + the PI-TRAFFIC:: + + from gpiozero import PiTraffic + + traffic = PiTraffic() + traffic.amber.on() + + To use the PI-TRAFFIC board when attached to a non-standard set of pins, + simply use the parent class, :class:`TrafficLights`. + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances to represent each + LED. If ``False`` (the default), construct regular :class:`LED` + instances. + + :param bool initial_value: + If ``False`` (the default), all LEDs will be off initially. If + ``None``, each device will be left in whatever state the pin is found + in when configured for output (warning: this can be on). If ``True``, + the device will be switched on initially. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Low Voltage Labs PI-TRAFFIC: http://lowvoltagelabs.com/products/pi-traffic/ + """ + def __init__(self, pwm=False, initial_value=False, pin_factory=None): + super(PiTraffic, self).__init__(9, 10, 11, + pwm=pwm, initial_value=initial_value, + pin_factory=pin_factory) + + +class PiStop(TrafficLights): + """ + Extends :class:`TrafficLights` for the `PiHardware Pi-Stop`_: a vertical + traffic lights board. + + The following example turns on the amber LED on a Pi-Stop + connected to location ``A+``:: + + from gpiozero import PiStop + + traffic = PiStop('A+') + traffic.amber.on() + + :param str location: + The `location`_ on the GPIO header to which the Pi-Stop is connected. + Must be one of: ``A``, ``A+``, ``B``, ``B+``, ``C``, ``D``. + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances to represent each + LED. If ``False`` (the default), construct regular :class:`LED` + instances. + + :param bool initial_value: + If ``False`` (the default), all LEDs will be off initially. If + ``None``, each device will be left in whatever state the pin is found + in when configured for output (warning: this can be on). If ``True``, + the device will be switched on initially. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _PiHardware Pi-Stop: https://pihw.wordpress.com/meltwaters-pi-hardware-kits/pi-stop/ + .. _location: https://github.com/PiHw/Pi-Stop/blob/master/markdown_source/markdown/Discover-PiStop.md + """ + LOCATIONS = { + 'A': (7, 8, 25), + 'A+': (21, 20, 16), + 'B': (10, 9, 11), + 'B+': (13, 19, 26), + 'C': (18, 15, 14), + 'D': (2, 3, 4), + } + + def __init__( + self, location=None, pwm=False, initial_value=False, + pin_factory=None): + gpios = self.LOCATIONS.get(location, None) + if gpios is None: + raise ValueError('location must be one of: %s' % + ', '.join(sorted(self.LOCATIONS.keys()))) + super(PiStop, self).__init__( + *gpios, pwm=pwm, initial_value=initial_value, + pin_factory=pin_factory + ) + + +class StatusZero(LEDBoard): + """ + Extends :class:`LEDBoard` for The Pi Hut's `STATUS Zero`_: a Pi Zero sized + add-on board with three sets of red/green LEDs to provide a status + indicator. + + The following example designates the first strip the label "wifi" and the + second "raining", and turns them green and red respectfully:: + + from gpiozero import StatusZero + + status = StatusZero('wifi', 'raining') + status.wifi.green.on() + status.raining.red.on() + + :param str \*labels: + Specify the names of the labels you wish to designate the strips to. + You can list up to three labels. If no labels are given, three strips + will be initialised with names 'one', 'two', and 'three'. If some, but + not all strips are given labels, any remaining strips will not be + initialised. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _STATUS Zero: https://thepihut.com/statuszero + """ + default_labels = ('one', 'two', 'three') + + def __init__(self, *labels, **kwargs): + pins = ( + (17, 4), + (22, 27), + (9, 10), + ) + pin_factory = kwargs.pop('pin_factory', None) + if len(labels) == 0: + labels = self.default_labels + elif len(labels) > len(pins): + raise ValueError("StatusZero doesn't support more than three labels") + dup, count = Counter(labels).most_common(1)[0] + if count > 1: + raise ValueError("Duplicate label %s" % dup) + super(StatusZero, self).__init__( + _order=labels, pin_factory=pin_factory, **{ + label: LEDBoard( + red=red, green=green, _order=('red', 'green'), + pin_factory=pin_factory, **kwargs + ) + for (green, red), label in zip(pins, labels) + } + ) + + +class StatusBoard(CompositeOutputDevice): + """ + Extends :class:`CompositeOutputDevice` for The Pi Hut's `STATUS`_ board: a + HAT sized add-on board with five sets of red/green LEDs and buttons to + provide a status indicator with additional input. + + The following example designates the first strip the label "wifi" and the + second "raining", turns the wifi green and then activates the button to + toggle its lights when pressed:: + + from gpiozero import StatusBoard + + status = StatusBoard('wifi', 'raining') + status.wifi.lights.green.on() + status.wifi.button.when_pressed = status.wifi.lights.toggle + + :param str \*labels: + Specify the names of the labels you wish to designate the strips to. + You can list up to five labels. If no labels are given, five strips + will be initialised with names 'one' to 'five'. If some, but not all + strips are given labels, any remaining strips will not be initialised. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _STATUS: https://thepihut.com/status + """ + default_labels = ('one', 'two', 'three', 'four', 'five') + + def __init__(self, *labels, **kwargs): + pins = ( + (17, 4, 14), + (22, 27, 19), + (9, 10, 15), + (5, 11, 26), + (13, 6, 18), + ) + pin_factory = kwargs.pop('pin_factory', None) + if len(labels) == 0: + labels = self.default_labels + elif len(labels) > len(pins): + raise ValueError("StatusBoard doesn't support more than five labels") + dup, count = Counter(labels).most_common(1)[0] + if count > 1: + raise ValueError("Duplicate label %s" % dup) + super(StatusBoard, self).__init__( + _order=labels, pin_factory=pin_factory, **{ + label: CompositeOutputDevice( + button=Button(button, pin_factory=pin_factory), + lights=LEDBoard( + red=red, green=green, _order=('red', 'green'), + pin_factory=pin_factory, **kwargs + ), _order=('button', 'lights'), pin_factory=pin_factory + ) + for (green, red, button), label in zip(pins, labels) + } + ) + + +class SnowPi(LEDBoard): + """ + Extends :class:`LEDBoard` for the `Ryanteck SnowPi`_ board. + + The SnowPi pins are fixed and therefore there's no need to specify them + when constructing this class. The following example turns on the eyes, sets + the nose pulsing, and the arms blinking:: + + from gpiozero import SnowPi + + snowman = SnowPi(pwm=True) + snowman.eyes.on() + snowman.nose.pulse() + snowman.arms.blink() + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances to represent each + LED. If ``False`` (the default), construct regular :class:`LED` + instances. + + :param bool initial_value: + If ``False`` (the default), all LEDs will be off initially. If + ``None``, each device will be left in whatever state the pin is found + in when configured for output (warning: this can be on). If ``True``, + the device will be switched on initially. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Ryanteck SnowPi: https://ryanteck.uk/raspberry-pi/114-snowpi-the-gpio-snowman-for-raspberry-pi-0635648608303.html + """ + def __init__(self, pwm=False, initial_value=False, pin_factory=None): + super(SnowPi, self).__init__( + arms=LEDBoard( + left=LEDBoard( + top=17, middle=18, bottom=22, + pwm=pwm, initial_value=initial_value, + _order=('top', 'middle', 'bottom'), + pin_factory=pin_factory), + right=LEDBoard( + top=7, middle=8, bottom=9, + pwm=pwm, initial_value=initial_value, + _order=('top', 'middle', 'bottom'), + pin_factory=pin_factory), + _order=('left', 'right'), + pin_factory=pin_factory + ), + eyes=LEDBoard( + left=23, right=24, + pwm=pwm, initial_value=initial_value, + _order=('left', 'right'), + pin_factory=pin_factory + ), + nose=25, + pwm=pwm, initial_value=initial_value, + _order=('eyes', 'nose', 'arms'), + pin_factory=pin_factory + ) + + +class TrafficLightsBuzzer(CompositeOutputDevice): + """ + Extends :class:`CompositeOutputDevice` and is a generic class for HATs with + traffic lights, a button and a buzzer. + + :param TrafficLights lights: + An instance of :class:`TrafficLights` representing the traffic lights + of the HAT. + + :param Buzzer buzzer: + An instance of :class:`Buzzer` representing the buzzer on the HAT. + + :param Button button: + An instance of :class:`Button` representing the button on the HAT. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + + def __init__(self, lights, buzzer, button, pin_factory=None): + super(TrafficLightsBuzzer, self).__init__( + lights=lights, buzzer=buzzer, button=button, + _order=('lights', 'buzzer', 'button'), + pin_factory=pin_factory + ) + + +class FishDish(TrafficLightsBuzzer): + """ + Extends :class:`TrafficLightsBuzzer` for the `Pi Supply FishDish`_: traffic + light LEDs, a button and a buzzer. + + The FishDish pins are fixed and therefore there's no need to specify them + when constructing this class. The following example waits for the button + to be pressed on the FishDish, then turns on all the LEDs:: + + from gpiozero import FishDish + + fish = FishDish() + fish.button.wait_for_press() + fish.lights.on() + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances to represent each + LED. If ``False`` (the default), construct regular :class:`LED` + instances. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Pi Supply FishDish: https://www.pi-supply.com/product/fish-dish-raspberry-pi-led-buzzer-board/ + """ + + def __init__(self, pwm=False, pin_factory=None): + super(FishDish, self).__init__( + TrafficLights(9, 22, 4, pwm=pwm, pin_factory=pin_factory), + Buzzer(8, pin_factory=pin_factory), + Button(7, pull_up=False, pin_factory=pin_factory), + pin_factory=pin_factory + ) + + +class TrafficHat(TrafficLightsBuzzer): + """ + Extends :class:`TrafficLightsBuzzer` for the `Ryanteck Traffic HAT`_: traffic + light LEDs, a button and a buzzer. + + The Traffic HAT pins are fixed and therefore there's no need to specify + them when constructing this class. The following example waits for the + button to be pressed on the Traffic HAT, then turns on all the LEDs:: + + from gpiozero import TrafficHat + + hat = TrafficHat() + hat.button.wait_for_press() + hat.lights.on() + + :param bool pwm: + If ``True``, construct :class:`PWMLED` instances to represent each + LED. If ``False`` (the default), construct regular :class:`LED` + instances. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Ryanteck Traffic HAT: https://ryanteck.uk/hats/1-traffichat-0635648607122.html + """ + + def __init__(self, pwm=False, pin_factory=None): + super(TrafficHat, self).__init__( + TrafficLights(24, 23, 22, pwm=pwm, pin_factory=pin_factory), + Buzzer(5, pin_factory=pin_factory), + Button(25, pin_factory=pin_factory), + pin_factory=pin_factory + ) + + +class Robot(SourceMixin, CompositeDevice): + """ + Extends :class:`CompositeDevice` to represent a generic dual-motor robot. + + This class is constructed with two tuples representing the forward and + backward pins of the left and right controllers respectively. For example, + if the left motor's controller is connected to GPIOs 4 and 14, while the + right motor's controller is connected to GPIOs 17 and 18 then the following + example will drive the robot forward:: + + from gpiozero import Robot + + robot = Robot(left=(4, 14), right=(17, 18)) + robot.forward() + + :param tuple left: + A tuple of two GPIO pins representing the forward and backward inputs + of the left motor's controller. + + :param tuple right: + A tuple of two GPIO pins representing the forward and backward inputs + of the right motor's controller. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + + def __init__(self, left=None, right=None, pin_factory=None): + super(Robot, self).__init__( + left_motor=Motor(*left, pin_factory=pin_factory), + right_motor=Motor(*right, pin_factory=pin_factory), + _order=('left_motor', 'right_motor'), + pin_factory=pin_factory + ) + + @property + def value(self): + """ + Represents the motion of the robot as a tuple of (left_motor_speed, + right_motor_speed) with ``(-1, -1)`` representing full speed backwards, + ``(1, 1)`` representing full speed forwards, and ``(0, 0)`` + representing stopped. + """ + return super(Robot, self).value + + @value.setter + def value(self, value): + self.left_motor.value, self.right_motor.value = value + + def forward(self, speed=1, **kwargs): + """ + Drive the robot forward by running both motors forward. + + :param float speed: + Speed at which to drive the motors, as a value between 0 (stopped) + and 1 (full speed). The default is 1. + + :param float curve_left: + The amount to curve left while moving forwards, by driving the + left motor at a slower speed. Maximum ``curve_left`` is 1, the + default is 0 (no curve). This parameter can only be specified as a + keyword parameter, and is mutually exclusive with ``curve_right``. + + :param float curve_right: + The amount to curve right while moving forwards, by driving the + right motor at a slower speed. Maximum ``curve_right`` is 1, the + default is 0 (no curve). This parameter can only be specified as a + keyword parameter, and is mutually exclusive with ``curve_left``. + """ + curve_left = kwargs.pop('curve_left', 0) + curve_right = kwargs.pop('curve_right', 0) + if kwargs: + raise TypeError('unexpected argument %s' % kwargs.popitem()[0]) + if not 0 <= curve_left <= 1: + raise ValueError('curve_left must be between 0 and 1') + if not 0 <= curve_right <= 1: + raise ValueError('curve_right must be between 0 and 1') + if curve_left != 0 and curve_right != 0: + raise ValueError('curve_left and curve_right can\'t be used at the same time') + self.left_motor.forward(speed * (1 - curve_left)) + self.right_motor.forward(speed * (1 - curve_right)) + + def backward(self, speed=1, **kwargs): + """ + Drive the robot backward by running both motors backward. + + :param float speed: + Speed at which to drive the motors, as a value between 0 (stopped) + and 1 (full speed). The default is 1. + + :param float curve_left: + The amount to curve left while moving backwards, by driving the + left motor at a slower speed. Maximum ``curve_left`` is 1, the + default is 0 (no curve). This parameter can only be specified as a + keyword parameter, and is mutually exclusive with ``curve_right``. + + :param float curve_right: + The amount to curve right while moving backwards, by driving the + right motor at a slower speed. Maximum ``curve_right`` is 1, the + default is 0 (no curve). This parameter can only be specified as a + keyword parameter, and is mutually exclusive with ``curve_left``. + """ + curve_left = kwargs.pop('curve_left', 0) + curve_right = kwargs.pop('curve_right', 0) + if kwargs: + raise TypeError('unexpected argument %s' % kwargs.popitem()[0]) + if not 0 <= curve_left <= 1: + raise ValueError('curve_left must be between 0 and 1') + if not 0 <= curve_right <= 1: + raise ValueError('curve_right must be between 0 and 1') + if curve_left != 0 and curve_right != 0: + raise ValueError('curve_left and curve_right can\'t be used at the same time') + self.left_motor.backward(speed * (1 - curve_left)) + self.right_motor.backward(speed * (1 - curve_right)) + + def left(self, speed=1): + """ + Make the robot turn left by running the right motor forward and left + motor backward. + + :param float speed: + Speed at which to drive the motors, as a value between 0 (stopped) + and 1 (full speed). The default is 1. + """ + self.right_motor.forward(speed) + self.left_motor.backward(speed) + + def right(self, speed=1): + """ + Make the robot turn right by running the left motor forward and right + motor backward. + + :param float speed: + Speed at which to drive the motors, as a value between 0 (stopped) + and 1 (full speed). The default is 1. + """ + self.left_motor.forward(speed) + self.right_motor.backward(speed) + + def reverse(self): + """ + Reverse the robot's current motor directions. If the robot is currently + running full speed forward, it will run full speed backward. If the + robot is turning left at half-speed, it will turn right at half-speed. + If the robot is currently stopped it will remain stopped. + """ + self.left_motor.reverse() + self.right_motor.reverse() + + def stop(self): + """ + Stop the robot. + """ + self.left_motor.stop() + self.right_motor.stop() + + +class RyanteckRobot(Robot): + """ + Extends :class:`Robot` for the `Ryanteck motor controller board`_. + + The Ryanteck MCB pins are fixed and therefore there's no need to specify + them when constructing this class. The following example drives the robot + forward:: + + from gpiozero import RyanteckRobot + + robot = RyanteckRobot() + robot.forward() + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Ryanteck motor controller board: https://ryanteck.uk/add-ons/6-ryanteck-rpi-motor-controller-board-0635648607160.html + """ + + def __init__(self, pin_factory=None): + super(RyanteckRobot, self).__init__( + (17, 18), (22, 23), pin_factory=pin_factory + ) + + +class CamJamKitRobot(Robot): + """ + Extends :class:`Robot` for the `CamJam #3 EduKit`_ motor controller board. + + The CamJam robot controller pins are fixed and therefore there's no need + to specify them when constructing this class. The following example drives + the robot forward:: + + from gpiozero import CamJamKitRobot + + robot = CamJamKitRobot() + robot.forward() + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _CamJam #3 EduKit: http://camjam.me/?page_id=1035 + """ + + def __init__(self, pin_factory=None): + super(CamJamKitRobot, self).__init__( + (9, 10), (7, 8), pin_factory=pin_factory + ) + + +class PhaseEnableRobot(SourceMixin, CompositeDevice): + """ + Extends :class:`CompositeDevice` to represent a dual-motor robot based + around a Phase/Enable motor board. + + This class is constructed with two tuples representing the phase + (direction) and enable (speed) pins of the left and right controllers + respectively. For example, if the left motor's controller is connected to + GPIOs 12 and 5, while the right motor's controller is connected to GPIOs 13 + and 6 so the following example will drive the robot forward:: + + from gpiozero import PhaseEnableRobot + + robot = PhaseEnableRobot(left=(5, 12), right=(6, 13)) + robot.forward() + + :param tuple left: + A tuple of two GPIO pins representing the phase and enable inputs + of the left motor's controller. + + :param tuple right: + A tuple of two GPIO pins representing the phase and enable inputs + of the right motor's controller. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + + def __init__(self, left=None, right=None, pin_factory=None): + super(PhaseEnableRobot, self).__init__( + left_motor=PhaseEnableMotor(*left, pin_factory=pin_factory), + right_motor=PhaseEnableMotor(*right, pin_factory=pin_factory), + _order=('left_motor', 'right_motor'), + pin_factory=pin_factory + ) + + @property + def value(self): + """ + Returns a tuple of two floating point values (-1 to 1) representing the + speeds of the robot's two motors (left and right). This property can + also be set to alter the speed of both motors. + """ + return super(PhaseEnableRobot, self).value + + @value.setter + def value(self, value): + self.left_motor.value, self.right_motor.value = value + + def forward(self, speed=1): + """ + Drive the robot forward by running both motors forward. + + :param float speed: + Speed at which to drive the motors, as a value between 0 (stopped) + and 1 (full speed). The default is 1. + """ + self.left_motor.forward(speed) + self.right_motor.forward(speed) + + def backward(self, speed=1): + """ + Drive the robot backward by running both motors backward. + + :param float speed: + Speed at which to drive the motors, as a value between 0 (stopped) + and 1 (full speed). The default is 1. + """ + self.left_motor.backward(speed) + self.right_motor.backward(speed) + + def left(self, speed=1): + """ + Make the robot turn left by running the right motor forward and left + motor backward. + + :param float speed: + Speed at which to drive the motors, as a value between 0 (stopped) + and 1 (full speed). The default is 1. + """ + self.right_motor.forward(speed) + self.left_motor.backward(speed) + + def right(self, speed=1): + """ + Make the robot turn right by running the left motor forward and right + motor backward. + + :param float speed: + Speed at which to drive the motors, as a value between 0 (stopped) + and 1 (full speed). The default is 1. + """ + self.left_motor.forward(speed) + self.right_motor.backward(speed) + + def reverse(self): + """ + Reverse the robot's current motor directions. If the robot is currently + running full speed forward, it will run full speed backward. If the + robot is turning left at half-speed, it will turn right at half-speed. + If the robot is currently stopped it will remain stopped. + """ + self.left_motor.value = -self.left_motor.value + self.right_motor.value = -self.right_motor.value + + def stop(self): + """ + Stop the robot. + """ + self.left_motor.stop() + self.right_motor.stop() + + +class PololuDRV8835Robot(PhaseEnableRobot): + """ + Extends :class:`PhaseEnableRobot` for the `Pololu DRV8835 Dual Motor Driver + Kit`_. + + The Pololu DRV8835 pins are fixed and therefore there's no need to specify + them when constructing this class. The following example drives the robot + forward:: + + from gpiozero import PololuDRV8835Robot + + robot = PololuDRV8835Robot() + robot.forward() + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Pololu DRV8835 Dual Motor Driver Kit: https://www.pololu.com/product/2753 + """ + + def __init__(self, pin_factory=None): + super(PololuDRV8835Robot, self).__init__( + (5, 12), (6, 13), pin_factory=pin_factory + ) + + +class _EnergenieMaster(SharedMixin, CompositeOutputDevice): + def __init__(self, pin_factory=None): + self._lock = Lock() + super(_EnergenieMaster, self).__init__( + *( + OutputDevice(pin, pin_factory=pin_factory) + for pin in (17, 22, 23, 27) + ), + mode=OutputDevice(24, pin_factory=pin_factory), + enable=OutputDevice(25, pin_factory=pin_factory), + _order=('mode', 'enable'), pin_factory=pin_factory + ) + + def close(self): + if getattr(self, '_lock', None): + with self._lock: + super(_EnergenieMaster, self).close() + self._lock = None + + @classmethod + def _shared_key(cls, pin_factory): + # There's only one Energenie master + return None + + def transmit(self, socket, enable): + with self._lock: + try: + code = (8 * bool(enable)) + (8 - socket) + for bit in self[:4]: + bit.value = (code & 1) + code >>= 1 + sleep(0.1) + self.enable.on() + sleep(0.25) + finally: + self.enable.off() + + +class Energenie(SourceMixin, Device): + """ + Extends :class:`Device` to represent an `Energenie socket`_ controller. + + This class is constructed with a socket number and an optional initial + state (defaults to ``False``, meaning off). Instances of this class can + be used to switch peripherals on and off. For example:: + + from gpiozero import Energenie + + lamp = Energenie(1) + lamp.on() + + :param int socket: + Which socket this instance should control. This is an integer number + between 1 and 4. + + :param bool initial_value: + The initial state of the socket. As Energenie sockets provide no + means of reading their state, you must provide an initial state for + the socket, which will be set upon construction. This defaults to + ``False`` which will switch the socket off. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _Energenie socket: https://energenie4u.co.uk/index.php/catalogue/product/ENER002-2PI + """ + + def __init__(self, socket=None, initial_value=False, pin_factory=None): + if socket is None: + raise EnergenieSocketMissing('socket number must be provided') + if not (1 <= socket <= 4): + raise EnergenieBadSocket('socket number must be between 1 and 4') + self._value = None + super(Energenie, self).__init__(pin_factory=pin_factory) + self._socket = socket + self._master = _EnergenieMaster(pin_factory=pin_factory) + if initial_value: + self.on() + else: + self.off() + + def close(self): + if getattr(self, '_master', None): + self._master.close() + self._master = None + + @property + def closed(self): + return self._master is None + + def __repr__(self): + try: + self._check_open() + return "" % self._socket + except DeviceClosed: + return "" + + @property + def value(self): + return self._value + + @value.setter + def value(self, value): + value = bool(value) + self._master.transmit(self._socket, value) + self._value = value + + def on(self): + self.value = True + + def off(self): + self.value = False diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/compat.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/compat.py new file mode 100644 index 00000000..0549c770 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/compat.py @@ -0,0 +1,140 @@ +# vim: set fileencoding=utf-8: + +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + +import cmath +import weakref +import collections +import operator +import functools + + +# Back-ported from python 3.5; see +# github.com/PythonCHB/close_pep/blob/master/is_close.py for original +# implementation +def isclose(a, b, rel_tol=1e-9, abs_tol=0.0): + if rel_tol < 0.0 or abs_tol < 0.0: + raise ValueError('error tolerances must be non-negative') + if a == b: # fast-path for exact equality + return True + if cmath.isinf(a) or cmath.isinf(b): + return False + diff = abs(b - a) + return ( + (diff <= abs(rel_tol * b)) or + (diff <= abs(rel_tol * a)) or + (diff <= abs_tol) + ) + + +# Backported from py3.4 +def mean(data): + if iter(data) is data: + data = list(data) + n = len(data) + if not n: + raise ValueError('cannot calculate mean of empty data') + return sum(data) / n + + +# Backported from py3.4 +def median(data): + data = sorted(data) + n = len(data) + if not n: + raise ValueError('cannot calculate median of empty data') + elif n % 2: + return data[n // 2] + else: + i = n // 2 + return (data[i - 1] + data[i]) / 2 + + +# Copied from the MIT-licensed https://github.com/slezica/python-frozendict +class frozendict(collections.Mapping): + def __init__(self, *args, **kwargs): + self.__dict = dict(*args, **kwargs) + self.__hash = None + + def __getitem__(self, key): + return self.__dict[key] + + def copy(self, **add_or_replace): + return frozendict(self, **add_or_replace) + + def __iter__(self): + return iter(self.__dict) + + def __len__(self): + return len(self.__dict) + + def __repr__(self): + return '' % repr(self.__dict) + + def __hash__(self): + if self.__hash is None: + hashes = map(hash, self.items()) + self.__hash = functools.reduce(operator.xor, hashes, 0) + return self.__hash + + +# Backported from py3.4 +class WeakMethod(weakref.ref): + """ + A custom `weakref.ref` subclass which simulates a weak reference to + a bound method, working around the lifetime problem of bound methods. + """ + + __slots__ = "_func_ref", "_meth_type", "_alive", "__weakref__" + + def __new__(cls, meth, callback=None): + try: + obj = meth.__self__ + func = meth.__func__ + except AttributeError: + raise TypeError("argument should be a bound method, not {0}" + .format(type(meth))) + def _cb(arg): + # The self-weakref trick is needed to avoid creating a reference + # cycle. + self = self_wr() + if self._alive: + self._alive = False + if callback is not None: + callback(self) + self = weakref.ref.__new__(cls, obj, _cb) + self._func_ref = weakref.ref(func, _cb) + self._meth_type = type(meth) + self._alive = True + self_wr = weakref.ref(self) + return self + + def __call__(self): + obj = super(WeakMethod, self).__call__() + func = self._func_ref() + if obj is None or func is None: + return None + return self._meth_type(func, obj) + + def __eq__(self, other): + if isinstance(other, WeakMethod): + if not self._alive or not other._alive: + return self is other + return weakref.ref.__eq__(self, other) and self._func_ref == other._func_ref + return False + + def __ne__(self, other): + if isinstance(other, WeakMethod): + if not self._alive or not other._alive: + return self is not other + return weakref.ref.__ne__(self, other) or self._func_ref != other._func_ref + return True + + __hash__ = weakref.ref.__hash__ + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/devices.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/devices.py new file mode 100644 index 00000000..1fefb725 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/devices.py @@ -0,0 +1,487 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, + ) +nstr = str +str = type('') + +import os +import atexit +import weakref +import warnings +from collections import namedtuple +from itertools import chain +from types import FunctionType +from threading import Lock + +# import pkg_resources + +from .pins import Pin +from .threads import _threads_shutdown +from .mixins import ( + ValuesMixin, + SharedMixin, + ) +from .exc import ( + BadPinFactory, + DeviceClosed, + CompositeDeviceBadName, + CompositeDeviceBadOrder, + CompositeDeviceBadDevice, + GPIOPinMissing, + GPIOPinInUse, + GPIODeviceClosed, + PinFactoryFallback, + ) +from .compat import frozendict + + +class GPIOMeta(type): + # NOTE Yes, this is a metaclass. Don't be scared - it's a simple one. + + def __new__(mcls, name, bases, cls_dict): + # Construct the class as normal + cls = super(GPIOMeta, mcls).__new__(mcls, name, bases, cls_dict) + # If there's a method in the class which has no docstring, search + # the base classes recursively for a docstring to copy + for attr_name, attr in cls_dict.items(): + if isinstance(attr, FunctionType) and not attr.__doc__: + for base_cls in cls.__mro__: + if hasattr(base_cls, attr_name): + base_fn = getattr(base_cls, attr_name) + if base_fn.__doc__: + attr.__doc__ = base_fn.__doc__ + break + return cls + + def __call__(cls, *args, **kwargs): + # Make sure cls has GPIOBase somewhere in its ancestry (otherwise + # setting __attrs__ below will be rather pointless) + assert issubclass(cls, GPIOBase) + if issubclass(cls, SharedMixin): + # If SharedMixin appears in the class' ancestry, convert the + # constructor arguments to a key and check whether an instance + # already exists. Only construct the instance if the key's new. + key = cls._shared_key(*args, **kwargs) + try: + self = cls._instances[key] + self._refs += 1 + except (KeyError, ReferenceError) as e: + self = super(GPIOMeta, cls).__call__(*args, **kwargs) + self._refs = 1 + # Replace the close method with one that merely decrements + # the refs counter and calls the original close method when + # it reaches zero + old_close = self.close + def close(): + self._refs = max(0, self._refs - 1) + if not self._refs: + try: + old_close() + finally: + try: + del cls._instances[key] + except KeyError: + # If the _refs go negative (too many closes) + # just ignore the resulting KeyError here - + # it's already gone + pass + self.close = close + cls._instances[key] = weakref.proxy(self) + else: + # Construct the instance as normal + self = super(GPIOMeta, cls).__call__(*args, **kwargs) + # At this point __new__ and __init__ have all been run. We now fix the + # set of attributes on the class by dir'ing the instance and creating a + # frozenset of the result called __attrs__ (which is queried by + # GPIOBase.__setattr__). An exception is made for SharedMixin devices + # which can be constructed multiple times, returning the same instance + if not issubclass(cls, SharedMixin) or self._refs == 1: + self.__attrs__ = frozenset(dir(self)) + return self + + +# Cross-version compatible method of using a metaclass +class GPIOBase(GPIOMeta(nstr('GPIOBase'), (), {})): + def __setattr__(self, name, value): + # This overridden __setattr__ simply ensures that additional attributes + # cannot be set on the class after construction (it manages this in + # conjunction with the meta-class above). Traditionally, this is + # managed with __slots__; however, this doesn't work with Python's + # multiple inheritance system which we need to use in order to avoid + # repeating the "source" and "values" property code in myriad places + if hasattr(self, '__attrs__') and name not in self.__attrs__: + raise AttributeError( + "'%s' object has no attribute '%s'" % ( + self.__class__.__name__, name)) + return super(GPIOBase, self).__setattr__(name, value) + + def __del__(self): + self.close() + + def close(self): + """ + Shut down the device and release all associated resources. This method + can be called on an already closed device without raising an exception. + + This method is primarily intended for interactive use at the command + line. It disables the device and releases its pin(s) for use by another + device. + + You can attempt to do this simply by deleting an object, but unless + you've cleaned up all references to the object this may not work (even + if you've cleaned up all references, there's still no guarantee the + garbage collector will actually delete the object at that point). By + contrast, the close method provides a means of ensuring that the object + is shut down. + + For example, if you have a breadboard with a buzzer connected to pin + 16, but then wish to attach an LED instead: + + >>> from gpiozero import * + >>> bz = Buzzer(16) + >>> bz.on() + >>> bz.off() + >>> bz.close() + >>> led = LED(16) + >>> led.blink() + + :class:`Device` descendents can also be used as context managers using + the :keyword:`with` statement. For example: + + >>> from gpiozero import * + >>> with Buzzer(16) as bz: + ... bz.on() + ... + >>> with LED(16) as led: + ... led.on() + ... + """ + # This is a placeholder which is simply here to ensure close() can be + # safely called from subclasses without worrying whether super-class' + # have it (which in turn is useful in conjunction with the SourceMixin + # class). + pass + + @property + def closed(self): + """ + Returns ``True`` if the device is closed (see the :meth:`close` + method). Once a device is closed you can no longer use any other + methods or properties to control or query the device. + """ + raise NotImplementedError + + def _check_open(self): + if self.closed: + raise DeviceClosed( + '%s is closed or uninitialized' % self.__class__.__name__) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.close() + + +class Device(ValuesMixin, GPIOBase): + """ + Represents a single device of any type; GPIO-based, SPI-based, I2C-based, + etc. This is the base class of the device hierarchy. It defines the basic + services applicable to all devices (specifically the :attr:`is_active` + property, the :attr:`value` property, and the :meth:`close` method). + """ + pin_factory = None # instance of a Factory sub-class + + def __init__(self, **kwargs): + # Force pin_factory to be keyword-only, even in Python 2 + pin_factory = kwargs.pop('pin_factory', None) + if pin_factory is None: + self.pin_factory = Device.pin_factory + else: + self.pin_factory = pin_factory + if kwargs: + raise TypeError("Device.__init__() got unexpected keyword " + "argument '%s'" % kwargs.popitem()[0]) + super(Device, self).__init__() + + def __repr__(self): + return "" % (self.__class__.__name__) + + def _conflicts_with(self, other): + """ + Called by :meth:`Factory.reserve_pins` to test whether the *other* + :class:`Device` using a common pin conflicts with this device's intent + to use it. The default is ``True`` indicating that all devices conflict + with common pins. Sub-classes may override this to permit more nuanced + replies. + """ + return True + + @property + def value(self): + """ + Returns a value representing the device's state. Frequently, this is a + boolean value, or a number between 0 and 1 but some devices use larger + ranges (e.g. -1 to +1) and composite devices usually use tuples to + return the states of all their subordinate components. + """ + raise NotImplementedError + + @property + def is_active(self): + """ + Returns ``True`` if the device is currently active and ``False`` + otherwise. This property is usually derived from :attr:`value`. Unlike + :attr:`value`, this is *always* a boolean. + """ + return bool(self.value) + + +class CompositeDevice(Device): + """ + Extends :class:`Device`. Represents a device composed of multiple devices + like simple HATs, H-bridge motor controllers, robots composed of multiple + motors, etc. + + The constructor accepts subordinate devices as positional or keyword + arguments. Positional arguments form unnamed devices accessed via the + :attr:`all` attribute, while keyword arguments are added to the device + as named (read-only) attributes. + + :param list _order: + If specified, this is the order of named items specified by keyword + arguments (to ensure that the :attr:`value` tuple is constructed with a + specific order). All keyword arguments *must* be included in the + collection. If omitted, an alphabetically sorted order will be selected + for keyword arguments. + """ + def __init__(self, *args, **kwargs): + self._all = () + self._named = frozendict({}) + self._namedtuple = None + self._order = kwargs.pop('_order', None) + pin_factory = kwargs.pop('pin_factory', None) + try: + if self._order is None: + self._order = sorted(kwargs.keys()) + else: + for missing_name in set(kwargs.keys()) - set(self._order): + raise CompositeDeviceBadOrder('%s missing from _order' % missing_name) + self._order = tuple(self._order) + for name in set(self._order) & set(dir(self)): + raise CompositeDeviceBadName('%s is a reserved name' % name) + for dev in chain(args, kwargs.values()): + if not isinstance(dev, Device): + raise CompositeDeviceBadDevice("%s doesn't inherit from Device" % dev) + self._named = frozendict(kwargs) + self._namedtuple = namedtuple('%sValue' % self.__class__.__name__, chain( + ('device_%d' % i for i in range(len(args))), self._order)) + except: + for dev in chain(args, kwargs.values()): + if isinstance(dev, Device): + dev.close() + raise + self._all = args + tuple(kwargs[v] for v in self._order) + super(CompositeDevice, self).__init__(pin_factory=pin_factory) + + def __getattr__(self, name): + # if _named doesn't exist yet, pretend it's an empty dict + if name == '_named': + return frozendict({}) + try: + return self._named[name] + except KeyError: + raise AttributeError("no such attribute %s" % name) + + def __setattr__(self, name, value): + # make named components read-only properties + if name in self._named: + raise AttributeError("can't set attribute %s" % name) + return super(CompositeDevice, self).__setattr__(name, value) + + def __repr__(self): + try: + self._check_open() + return "" % ( + self.__class__.__name__, + len(self), ','.join(self._order), + len(self) - len(self._named) + ) + except DeviceClosed: + return "" % (self.__class__.__name__) + + def __len__(self): + return len(self._all) + + def __getitem__(self, index): + return self._all[index] + + def __iter__(self): + return iter(self._all) + + @property + def all(self): + # XXX Deprecate this in favour of using the instance as a container + return self._all + + def close(self): + if getattr(self, '_all', None): + for device in self._all: + if isinstance(device, Device): + device.close() + self._all = () + + @property + def closed(self): + return all(device.closed for device in self) + + @property + def namedtuple(self): + return self._namedtuple + + @property + def value(self): + return self.namedtuple(*(device.value for device in self)) + + @property + def is_active(self): + return any(self.value) + + +class GPIODevice(Device): + """ + Extends :class:`Device`. Represents a generic GPIO device and provides + the services common to all single-pin GPIO devices (like ensuring two + GPIO devices do no share a :attr:`pin`). + + :param int pin: + The GPIO pin (in BCM numbering) that the device is connected to. If + this is ``None``, :exc:`GPIOPinMissing` will be raised. If the pin is + already in use by another device, :exc:`GPIOPinInUse` will be raised. + """ + def __init__(self, pin=None, **kwargs): + super(GPIODevice, self).__init__(**kwargs) + # self._pin must be set before any possible exceptions can be raised + # because it's accessed in __del__. However, it mustn't be given the + # value of pin until we've verified that it isn't already allocated + self._pin = None + if pin is None: + raise GPIOPinMissing('No pin given') + # Check you can reserve *before* constructing the pin + self.pin_factory.reserve_pins(self, pin) + pin = self.pin_factory.pin(pin) + self._pin = pin + self._active_state = True + self._inactive_state = False + + def _state_to_value(self, state): + return bool(state == self._active_state) + + def _read(self): + try: + return self._state_to_value(self.pin.state) + except (AttributeError, TypeError): + self._check_open() + raise + + def close(self): + super(GPIODevice, self).close() + if getattr(self, '_pin', None) is not None: + self.pin_factory.release_pins(self, self._pin.number) + self._pin.close() + self._pin = None + + @property + def closed(self): + return self._pin is None + + def _check_open(self): + try: + super(GPIODevice, self)._check_open() + except DeviceClosed as e: + # For backwards compatibility; GPIODeviceClosed is deprecated + raise GPIODeviceClosed(str(e)) + + @property + def pin(self): + """ + The :class:`Pin` that the device is connected to. This will be ``None`` + if the device has been closed (see the :meth:`close` method). When + dealing with GPIO pins, query ``pin.number`` to discover the GPIO + pin (in BCM numbering) that the device is connected to. + """ + return self._pin + + @property + def value(self): + return self._read() + + def __repr__(self): + try: + return "" % ( + self.__class__.__name__, self.pin, self.is_active) + except DeviceClosed: + return "" % self.__class__.__name__ + + +# Defined last to ensure Device is defined before attempting to load any pin +# factory; pin factories want to load spi which in turn relies on devices (for +# the soft-SPI implementation) +def _default_pin_factory(name=os.getenv('GPIOZERO_PIN_FACTORY', None)): + group = 'gpiozero_pin_factories' + if name is None: + # If no factory is explicitly specified, try various names in + # "preferred" order. Note that in this case we only select from + # gpiozero distribution so without explicitly specifying a name (via + # the environment) it's impossible to auto-select a factory from + # outside the base distribution + # + # We prefer RPi.GPIO here as it supports PWM, and all Pi revisions. If + # no third-party libraries are available, however, we fall back to a + # pure Python implementation which supports platforms like PyPy + dist = pkg_resources.get_distribution('gpiozero') + for name in ('rpigpio', 'rpio', 'pigpio', 'native'): + try: + return pkg_resources.load_entry_point(dist, group, name)() + except Exception as e: + warnings.warn( + PinFactoryFallback( + 'Falling back from %s: %s' % (name, str(e)))) + raise BadPinFactory('Unable to load any default pin factory!') + else: + # Try with the name verbatim first. If that fails, attempt with the + # lower-cased name (this ensures compatibility names work but we're + # still case insensitive for all factories) + for factory in pkg_resources.iter_entry_points(group, name): + return factory.load()() + for factory in pkg_resources.iter_entry_points(group, name.lower()): + return factory.load()() + raise BadPinFactory('Unable to find pin factory "%s"' % name) + + +def _devices_shutdown(): + if Device.pin_factory: + with Device.pin_factory._res_lock: + reserved_devices = { + dev + for ref_list in Device.pin_factory._reservations.values() + for ref in ref_list + for dev in (ref(),) + if dev is not None + } + for dev in reserved_devices: + dev.close() + Device.pin_factory.close() + Device.pin_factory = None + + +def _shutdown(): + _threads_shutdown() + _devices_shutdown() + + +#Device.pin_factory = _default_pin_factory() +atexit.register(_shutdown) diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/exc.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/exc.py new file mode 100644 index 00000000..f75ccc5c --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/exc.py @@ -0,0 +1,168 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, +) +str = type('') + + +class GPIOZeroError(Exception): + "Base class for all exceptions in GPIO Zero" + +class DeviceClosed(GPIOZeroError): + "Error raised when an operation is attempted on a closed device" + +class BadEventHandler(GPIOZeroError, ValueError): + "Error raised when an event handler with an incompatible prototype is specified" + +class BadWaitTime(GPIOZeroError, ValueError): + "Error raised when an invalid wait time is specified" + +class BadQueueLen(GPIOZeroError, ValueError): + "Error raised when non-positive queue length is specified" + +class BadPinFactory(GPIOZeroError, ImportError): + "Error raised when an unknown pin factory name is specified" + +class CompositeDeviceError(GPIOZeroError): + "Base class for errors specific to the CompositeDevice hierarchy" + +class CompositeDeviceBadName(CompositeDeviceError, ValueError): + "Error raised when a composite device is constructed with a reserved name" + +class CompositeDeviceBadOrder(CompositeDeviceError, ValueError): + "Error raised when a composite device is constructed with an incomplete order" + +class CompositeDeviceBadDevice(CompositeDeviceError, ValueError): + "Error raised when a composite device is constructed with an object that doesn't inherit from :class:`Device`" + +class EnergenieSocketMissing(CompositeDeviceError, ValueError): + "Error raised when socket number is not specified" + +class EnergenieBadSocket(CompositeDeviceError, ValueError): + "Error raised when an invalid socket number is passed to :class:`Energenie`" + +class SPIError(GPIOZeroError): + "Base class for errors related to the SPI implementation" + +class SPIBadArgs(SPIError, ValueError): + "Error raised when invalid arguments are given while constructing :class:`SPIDevice`" + +class SPIBadChannel(SPIError, ValueError): + "Error raised when an invalid channel is given to an :class:`AnalogInputDevice`" + +class SPIFixedClockMode(SPIError, AttributeError): + "Error raised when the SPI clock mode cannot be changed" + +class SPIInvalidClockMode(SPIError, ValueError): + "Error raised when an invalid clock mode is given to an SPI implementation" + +class SPIFixedBitOrder(SPIError, AttributeError): + "Error raised when the SPI bit-endianness cannot be changed" + +class SPIFixedSelect(SPIError, AttributeError): + "Error raised when the SPI select polarity cannot be changed" + +class SPIFixedWordSize(SPIError, AttributeError): + "Error raised when the number of bits per word cannot be changed" + +class SPIInvalidWordSize(SPIError, ValueError): + "Error raised when an invalid (out of range) number of bits per word is specified" + +class GPIODeviceError(GPIOZeroError): + "Base class for errors specific to the GPIODevice hierarchy" + +class GPIODeviceClosed(GPIODeviceError, DeviceClosed): + "Deprecated descendent of :exc:`DeviceClosed`" + +class GPIOPinInUse(GPIODeviceError): + "Error raised when attempting to use a pin already in use by another device" + +class GPIOPinMissing(GPIODeviceError, ValueError): + "Error raised when a pin specification is not given" + +class InputDeviceError(GPIODeviceError): + "Base class for errors specific to the InputDevice hierarchy" + +class OutputDeviceError(GPIODeviceError): + "Base class for errors specified to the OutputDevice hierarchy" + +class OutputDeviceBadValue(OutputDeviceError, ValueError): + "Error raised when ``value`` is set to an invalid value" + +class PinError(GPIOZeroError): + "Base class for errors related to pin implementations" + +class PinInvalidFunction(PinError, ValueError): + "Error raised when attempting to change the function of a pin to an invalid value" + +class PinInvalidState(PinError, ValueError): + "Error raised when attempting to assign an invalid state to a pin" + +class PinInvalidPull(PinError, ValueError): + "Error raised when attempting to assign an invalid pull-up to a pin" + +class PinInvalidEdges(PinError, ValueError): + "Error raised when attempting to assign an invalid edge detection to a pin" + +class PinInvalidBounce(PinError, ValueError): + "Error raised when attempting to assign an invalid bounce time to a pin" + +class PinSetInput(PinError, AttributeError): + "Error raised when attempting to set a read-only pin" + +class PinFixedPull(PinError, AttributeError): + "Error raised when attempting to set the pull of a pin with fixed pull-up" + +class PinEdgeDetectUnsupported(PinError, AttributeError): + "Error raised when attempting to use edge detection on unsupported pins" + +class PinUnsupported(PinError, NotImplementedError): + "Error raised when attempting to obtain a pin interface on unsupported pins" + +class PinSPIUnsupported(PinError, NotImplementedError): + "Error raised when attempting to obtain an SPI interface on unsupported pins" + +class PinPWMError(PinError): + "Base class for errors related to PWM implementations" + +class PinPWMUnsupported(PinPWMError, AttributeError): + "Error raised when attempting to activate PWM on unsupported pins" + +class PinPWMFixedValue(PinPWMError, AttributeError): + "Error raised when attempting to initialize PWM on an input pin" + +class PinUnknownPi(PinError, RuntimeError): + "Error raised when gpiozero doesn't recognize a revision of the Pi" + +class PinMultiplePins(PinError, RuntimeError): + "Error raised when multiple pins support the requested function" + +class PinNoPins(PinError, RuntimeError): + "Error raised when no pins support the requested function" + +class PinInvalidPin(PinError, ValueError): + "Error raised when an invalid pin specification is provided" + +class GPIOZeroWarning(Warning): + "Base class for all warnings in GPIO Zero" + +class DistanceSensorNoEcho(GPIOZeroWarning): + "Warning raised when the distance sensor sees no echo at all" + +class SPIWarning(GPIOZeroWarning): + "Base class for warnings related to the SPI implementation" + +class SPISoftwareFallback(SPIWarning): + "Warning raised when falling back to the software implementation" + +class PinWarning(GPIOZeroWarning): + "Base class for warnings related to pin implementations" + +class PinFactoryFallback(PinWarning): + "Warning raised when a default pin factory fails to load and a fallback is tried" + +class PinNonPhysical(PinWarning): + "Warning raised when a non-physical pin is specified in a constructor" + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/input_devices.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/input_devices.py new file mode 100644 index 00000000..3762a4c4 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/input_devices.py @@ -0,0 +1,790 @@ +# vim: set fileencoding=utf-8: + +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, +) + +import warnings +from time import sleep, time +from threading import Event, Lock +try: + from statistics import median +except ImportError: + from .compat import median + +from .exc import InputDeviceError, DeviceClosed, DistanceSensorNoEcho +from .devices import GPIODevice +from .mixins import GPIOQueue, EventsMixin, HoldMixin + + +class InputDevice(GPIODevice): + """ + Represents a generic GPIO input device. + + This class extends :class:`GPIODevice` to add facilities common to GPIO + input devices. The constructor adds the optional *pull_up* parameter to + specify how the pin should be pulled by the internal resistors. The + :attr:`~GPIODevice.is_active` property is adjusted accordingly so that + ``True`` still means active regardless of the :attr:`pull_up` setting. + + :param int pin: + The GPIO pin (in Broadcom numbering) that the device is connected to. + If this is ``None`` a :exc:`GPIODeviceError` will be raised. + + :param bool pull_up: + If ``True``, the pin will be pulled high with an internal resistor. If + ``False`` (the default), the pin will be pulled low. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__(self, pin=None, pull_up=False, pin_factory=None): + super(InputDevice, self).__init__(pin, pin_factory=pin_factory) + try: + self.pin.function = 'input' + pull = 'up' if pull_up else 'down' + if self.pin.pull != pull: + self.pin.pull = pull + except: + self.close() + raise + self._active_state = False if pull_up else True + self._inactive_state = True if pull_up else False + + @property + def pull_up(self): + """ + If ``True``, the device uses a pull-up resistor to set the GPIO pin + "high" by default. + """ + return self.pin.pull == 'up' + + def __repr__(self): + try: + return "" % ( + self.__class__.__name__, self.pin, self.pull_up, self.is_active) + except: + return super(InputDevice, self).__repr__() + + +class DigitalInputDevice(EventsMixin, InputDevice): + """ + Represents a generic input device with typical on/off behaviour. + + This class extends :class:`InputDevice` with machinery to fire the active + and inactive events for devices that operate in a typical digital manner: + straight forward on / off states with (reasonably) clean transitions + between the two. + + :param float bounce_time: + Specifies the length of time (in seconds) that the component will + ignore changes in state after an initial change. This defaults to + ``None`` which indicates that no bounce compensation will be performed. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, pin=None, pull_up=False, bounce_time=None, pin_factory=None): + super(DigitalInputDevice, self).__init__( + pin, pull_up, pin_factory=pin_factory + ) + try: + self.pin.bounce = bounce_time + self.pin.edges = 'both' + self.pin.when_changed = self._fire_events + # Call _fire_events once to set initial state of events + self._fire_events() + except: + self.close() + raise + + +class SmoothedInputDevice(EventsMixin, InputDevice): + """ + Represents a generic input device which takes its value from the average of + a queue of historical values. + + This class extends :class:`InputDevice` with a queue which is filled by a + background thread which continually polls the state of the underlying + device. The average (a configurable function) of the values in the queue is + compared to a threshold which is used to determine the state of the + :attr:`is_active` property. + + .. note:: + + The background queue is not automatically started upon construction. + This is to allow descendents to set up additional components before the + queue starts reading values. Effectively this is an abstract base + class. + + This class is intended for use with devices which either exhibit analog + behaviour (such as the charging time of a capacitor with an LDR), or those + which exhibit "twitchy" behaviour (such as certain motion sensors). + + :param float threshold: + The value above which the device will be considered "on". + + :param int queue_len: + The length of the internal queue which is filled by the background + thread. + + :param float sample_wait: + The length of time to wait between retrieving the state of the + underlying device. Defaults to 0.0 indicating that values are retrieved + as fast as possible. + + :param bool partial: + If ``False`` (the default), attempts to read the state of the device + (from the :attr:`is_active` property) will block until the queue has + filled. If ``True``, a value will be returned immediately, but be + aware that this value is likely to fluctuate excessively. + + :param average: + The function used to average the values in the internal queue. This + defaults to :func:`statistics.median` which a good selection for + discarding outliers from jittery sensors. The function specific must + accept a sequence of numbers and return a single number. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, pin=None, pull_up=False, threshold=0.5, queue_len=5, + sample_wait=0.0, partial=False, average=median, pin_factory=None): + self._queue = None + super(SmoothedInputDevice, self).__init__( + pin, pull_up, pin_factory=pin_factory + ) + try: + self._queue = GPIOQueue(self, queue_len, sample_wait, partial, average) + self.threshold = float(threshold) + except: + self.close() + raise + + def close(self): + try: + self._queue.stop() + except AttributeError: + # If the queue isn't initialized (it's None), or _queue hasn't been + # set ignore the error because we're trying to close anyway + if getattr(self, '_queue', None) is not None: + raise + except RuntimeError: + # Cannot join thread before it starts; we don't care about this + # because we're trying to close the thread anyway + pass + self._queue = None + super(SmoothedInputDevice, self).close() + + def __repr__(self): + try: + self._check_open() + except DeviceClosed: + return super(SmoothedInputDevice, self).__repr__() + else: + if self.partial or self._queue.full.is_set(): + return super(SmoothedInputDevice, self).__repr__() + else: + return "" % ( + self.__class__.__name__, self.pin, self.pull_up) + + @property + def queue_len(self): + """ + The length of the internal queue of values which is averaged to + determine the overall state of the device. This defaults to 5. + """ + self._check_open() + return self._queue.queue.maxlen + + @property + def partial(self): + """ + If ``False`` (the default), attempts to read the :attr:`value` or + :attr:`is_active` properties will block until the queue has filled. + """ + self._check_open() + return self._queue.partial + + @property + def value(self): + """ + Returns the mean of the values in the internal queue. This is compared + to :attr:`threshold` to determine whether :attr:`is_active` is + ``True``. + """ + self._check_open() + return self._queue.value + + @property + def threshold(self): + """ + If :attr:`value` exceeds this amount, then :attr:`is_active` will + return ``True``. + """ + return self._threshold + + @threshold.setter + def threshold(self, value): + if not (0.0 < value < 1.0): + raise InputDeviceError( + 'threshold must be between zero and one exclusive' + ) + self._threshold = float(value) + + @property + def is_active(self): + """ + Returns ``True`` if the device is currently active and ``False`` + otherwise. + """ + return self.value > self.threshold + + +class Button(HoldMixin, DigitalInputDevice): + """ + Extends :class:`DigitalInputDevice` and represents a simple push button + or switch. + + Connect one side of the button to a ground pin, and the other to any GPIO + pin. Alternatively, connect one side of the button to the 3V3 pin, and the + other to any GPIO pin, then set *pull_up* to ``False`` in the + :class:`Button` constructor. + + The following example will print a line of text when the button is pushed:: + + from gpiozero import Button + + button = Button(4) + button.wait_for_press() + print("The button was pressed!") + + :param int pin: + The GPIO pin which the button is attached to. See :ref:`pin-numbering` + for valid pin numbers. + + :param bool pull_up: + If ``True`` (the default), the GPIO pin will be pulled high by default. + In this case, connect the other side of the button to ground. If + ``False``, the GPIO pin will be pulled low by default. In this case, + connect the other side of the button to 3V3. + + :param float bounce_time: + If ``None`` (the default), no software bounce compensation will be + performed. Otherwise, this is the length of time (in seconds) that the + component will ignore changes in state after an initial change. + + :param float hold_time: + The length of time (in seconds) to wait after the button is pushed, + until executing the :attr:`when_held` handler. Defaults to ``1``. + + :param bool hold_repeat: + If ``True``, the :attr:`when_held` handler will be repeatedly executed + as long as the device remains active, every *hold_time* seconds. If + ``False`` (the default) the :attr:`when_held` handler will be only be + executed once per hold. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, pin=None, pull_up=True, bounce_time=None, + hold_time=1, hold_repeat=False, pin_factory=None): + super(Button, self).__init__( + pin, pull_up, bounce_time, pin_factory=pin_factory + ) + self.hold_time = hold_time + self.hold_repeat = hold_repeat + +Button.is_pressed = Button.is_active +Button.pressed_time = Button.active_time +Button.when_pressed = Button.when_activated +Button.when_released = Button.when_deactivated +Button.wait_for_press = Button.wait_for_active +Button.wait_for_release = Button.wait_for_inactive + + +class LineSensor(SmoothedInputDevice): + """ + Extends :class:`SmoothedInputDevice` and represents a single pin line sensor + like the TCRT5000 infra-red proximity sensor found in the `CamJam #3 + EduKit`_. + + A typical line sensor has a small circuit board with three pins: VCC, GND, + and OUT. VCC should be connected to a 3V3 pin, GND to one of the ground + pins, and finally OUT to the GPIO specified as the value of the *pin* + parameter in the constructor. + + The following code will print a line of text indicating when the sensor + detects a line, or stops detecting a line:: + + from gpiozero import LineSensor + from signal import pause + + sensor = LineSensor(4) + sensor.when_line = lambda: print('Line detected') + sensor.when_no_line = lambda: print('No line detected') + pause() + + :param int pin: + The GPIO pin which the sensor is attached to. See :ref:`pin-numbering` + for valid pin numbers. + + :param int queue_len: + The length of the queue used to store values read from the sensor. This + defaults to 5. + + :param float sample_rate: + The number of values to read from the device (and append to the + internal queue) per second. Defaults to 100. + + :param float threshold: + Defaults to 0.5. When the mean of all values in the internal queue + rises above this value, the sensor will be considered "active" by the + :attr:`~SmoothedInputDevice.is_active` property, and all appropriate + events will be fired. + + :param bool partial: + When ``False`` (the default), the object will not return a value for + :attr:`~SmoothedInputDevice.is_active` until the internal queue has + filled with values. Only set this to ``True`` if you require values + immediately after object construction. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _CamJam #3 EduKit: http://camjam.me/?page_id=1035 + """ + def __init__( + self, pin=None, queue_len=5, sample_rate=100, threshold=0.5, + partial=False, pin_factory=None): + super(LineSensor, self).__init__( + pin, pull_up=False, threshold=threshold, + queue_len=queue_len, sample_wait=1 / sample_rate, partial=partial, + pin_factory=pin_factory + ) + try: + self._queue.start() + except: + self.close() + raise + + @property + def line_detected(self): + return not self.is_active + +LineSensor.when_line = LineSensor.when_deactivated +LineSensor.when_no_line = LineSensor.when_activated +LineSensor.wait_for_line = LineSensor.wait_for_inactive +LineSensor.wait_for_no_line = LineSensor.wait_for_active + + +class MotionSensor(SmoothedInputDevice): + """ + Extends :class:`SmoothedInputDevice` and represents a passive infra-red + (PIR) motion sensor like the sort found in the `CamJam #2 EduKit`_. + + .. _CamJam #2 EduKit: http://camjam.me/?page_id=623 + + A typical PIR device has a small circuit board with three pins: VCC, OUT, + and GND. VCC should be connected to a 5V pin, GND to one of the ground + pins, and finally OUT to the GPIO specified as the value of the *pin* + parameter in the constructor. + + The following code will print a line of text when motion is detected:: + + from gpiozero import MotionSensor + + pir = MotionSensor(4) + pir.wait_for_motion() + print("Motion detected!") + + :param int pin: + The GPIO pin which the sensor is attached to. See :ref:`pin-numbering` + for valid pin numbers. + + :param int queue_len: + The length of the queue used to store values read from the sensor. This + defaults to 1 which effectively disables the queue. If your motion + sensor is particularly "twitchy" you may wish to increase this value. + + :param float sample_rate: + The number of values to read from the device (and append to the + internal queue) per second. Defaults to 100. + + :param float threshold: + Defaults to 0.5. When the mean of all values in the internal queue + rises above this value, the sensor will be considered "active" by the + :attr:`~SmoothedInputDevice.is_active` property, and all appropriate + events will be fired. + + :param bool partial: + When ``False`` (the default), the object will not return a value for + :attr:`~SmoothedInputDevice.is_active` until the internal queue has + filled with values. Only set this to ``True`` if you require values + immediately after object construction. + + :param bool pull_up: + If ``False`` (the default), the GPIO pin will be pulled low by default. + If ``True``, the GPIO pin will be pulled high by the sensor. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, pin=None, queue_len=1, sample_rate=10, threshold=0.5, + partial=False, pull_up=False, pin_factory=None): + super(MotionSensor, self).__init__( + pin, pull_up=pull_up, threshold=threshold, + queue_len=queue_len, sample_wait=1 / sample_rate, partial=partial, + pin_factory=pin_factory + ) + try: + self._queue.start() + except: + self.close() + raise + +MotionSensor.motion_detected = MotionSensor.is_active +MotionSensor.when_motion = MotionSensor.when_activated +MotionSensor.when_no_motion = MotionSensor.when_deactivated +MotionSensor.wait_for_motion = MotionSensor.wait_for_active +MotionSensor.wait_for_no_motion = MotionSensor.wait_for_inactive + + +class LightSensor(SmoothedInputDevice): + """ + Extends :class:`SmoothedInputDevice` and represents a light dependent + resistor (LDR). + + Connect one leg of the LDR to the 3V3 pin; connect one leg of a 1µF + capacitor to a ground pin; connect the other leg of the LDR and the other + leg of the capacitor to the same GPIO pin. This class repeatedly discharges + the capacitor, then times the duration it takes to charge (which will vary + according to the light falling on the LDR). + + The following code will print a line of text when light is detected:: + + from gpiozero import LightSensor + + ldr = LightSensor(18) + ldr.wait_for_light() + print("Light detected!") + + :param int pin: + The GPIO pin which the sensor is attached to. See :ref:`pin-numbering` + for valid pin numbers. + + :param int queue_len: + The length of the queue used to store values read from the circuit. + This defaults to 5. + + :param float charge_time_limit: + If the capacitor in the circuit takes longer than this length of time + to charge, it is assumed to be dark. The default (0.01 seconds) is + appropriate for a 1µF capacitor coupled with the LDR from the + `CamJam #2 EduKit`_. You may need to adjust this value for different + valued capacitors or LDRs. + + :param float threshold: + Defaults to 0.1. When the mean of all values in the internal queue + rises above this value, the area will be considered "light", and all + appropriate events will be fired. + + :param bool partial: + When ``False`` (the default), the object will not return a value for + :attr:`~SmoothedInputDevice.is_active` until the internal queue has + filled with values. Only set this to ``True`` if you require values + immediately after object construction. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _CamJam #2 EduKit: http://camjam.me/?page_id=623 + """ + def __init__( + self, pin=None, queue_len=5, charge_time_limit=0.01, + threshold=0.1, partial=False, pin_factory=None): + super(LightSensor, self).__init__( + pin, pull_up=False, threshold=threshold, + queue_len=queue_len, sample_wait=0.0, partial=partial, + pin_factory=pin_factory + ) + try: + self._charge_time_limit = charge_time_limit + self._charged = Event() + self.pin.edges = 'rising' + self.pin.bounce = None + self.pin.when_changed = self._charged.set + self._queue.start() + except: + self.close() + raise + + @property + def charge_time_limit(self): + return self._charge_time_limit + + def _read(self): + # Drain charge from the capacitor + self.pin.function = 'output' + self.pin.state = False + sleep(0.1) + # Time the charging of the capacitor + start = time() + self._charged.clear() + self.pin.function = 'input' + self._charged.wait(self.charge_time_limit) + return ( + 1.0 - min(self.charge_time_limit, time() - start) / + self.charge_time_limit + ) + +LightSensor.light_detected = LightSensor.is_active +LightSensor.when_light = LightSensor.when_activated +LightSensor.when_dark = LightSensor.when_deactivated +LightSensor.wait_for_light = LightSensor.wait_for_active +LightSensor.wait_for_dark = LightSensor.wait_for_inactive + + +class DistanceSensor(SmoothedInputDevice): + """ + Extends :class:`SmoothedInputDevice` and represents an HC-SR04 ultrasonic + distance sensor, as found in the `CamJam #3 EduKit`_. + + The distance sensor requires two GPIO pins: one for the *trigger* (marked + TRIG on the sensor) and another for the *echo* (marked ECHO on the sensor). + However, a voltage divider is required to ensure the 5V from the ECHO pin + doesn't damage the Pi. Wire your sensor according to the following + instructions: + + 1. Connect the GND pin of the sensor to a ground pin on the Pi. + + 2. Connect the TRIG pin of the sensor a GPIO pin. + + 3. Connect one end of a 330Ω resistor to the ECHO pin of the sensor. + + 4. Connect one end of a 470Ω resistor to the GND pin of the sensor. + + 5. Connect the free ends of both resistors to another GPIO pin. This forms + the required `voltage divider`_. + + 6. Finally, connect the VCC pin of the sensor to a 5V pin on the Pi. + + .. note:: + + If you do not have the precise values of resistor specified above, + don't worry! What matters is the *ratio* of the resistors to each + other. + + You also don't need to be absolutely precise; the `voltage divider`_ + given above will actually output ~3V (rather than 3.3V). A simple 2:3 + ratio will give 3.333V which implies you can take three resistors of + equal value, use one of them instead of the 330Ω resistor, and two of + them in series instead of the 470Ω resistor. + + .. _voltage divider: https://en.wikipedia.org/wiki/Voltage_divider + + The following code will periodically report the distance measured by the + sensor in cm assuming the TRIG pin is connected to GPIO17, and the ECHO + pin to GPIO18:: + + from gpiozero import DistanceSensor + from time import sleep + + sensor = DistanceSensor(echo=18, trigger=17) + while True: + print('Distance: ', sensor.distance * 100) + sleep(1) + + :param int echo: + The GPIO pin which the ECHO pin is attached to. See + :ref:`pin-numbering` for valid pin numbers. + + :param int trigger: + The GPIO pin which the TRIG pin is attached to. See + :ref:`pin-numbering` for valid pin numbers. + + :param int queue_len: + The length of the queue used to store values read from the sensor. + This defaults to 30. + + :param float max_distance: + The :attr:`value` attribute reports a normalized value between 0 (too + close to measure) and 1 (maximum distance). This parameter specifies + the maximum distance expected in meters. This defaults to 1. + + :param float threshold_distance: + Defaults to 0.3. This is the distance (in meters) that will trigger the + ``in_range`` and ``out_of_range`` events when crossed. + + :param bool partial: + When ``False`` (the default), the object will not return a value for + :attr:`~SmoothedInputDevice.is_active` until the internal queue has + filled with values. Only set this to ``True`` if you require values + immediately after object construction. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + + .. _CamJam #3 EduKit: http://camjam.me/?page_id=1035 + """ + ECHO_LOCK = Lock() + + def __init__( + self, echo=None, trigger=None, queue_len=10, max_distance=1, + threshold_distance=0.3, partial=False, pin_factory=None): + if max_distance <= 0: + raise ValueError('invalid maximum distance (must be positive)') + self._trigger = None + super(DistanceSensor, self).__init__( + echo, pull_up=False, threshold=threshold_distance / max_distance, + queue_len=queue_len, sample_wait=0.06, partial=partial, + pin_factory=pin_factory + ) + try: + self.speed_of_sound = 343.26 # m/s + self._max_distance = max_distance + self._trigger = GPIODevice(trigger) + self._echo = Event() + self._echo_rise = None + self._echo_fall = None + self._trigger.pin.function = 'output' + self._trigger.pin.state = False + self.pin.edges = 'both' + self.pin.bounce = None + self.pin.when_changed = self._echo_changed + self._queue.start() + except: + self.close() + raise + + def close(self): + try: + self._trigger.close() + except AttributeError: + if getattr(self, '_trigger', None) is not None: + raise + self._trigger = None + super(DistanceSensor, self).close() + + @property + def max_distance(self): + """ + The maximum distance that the sensor will measure in meters. This value + is specified in the constructor and is used to provide the scaling + for the :attr:`value` attribute. When :attr:`distance` is equal to + :attr:`max_distance`, :attr:`value` will be 1. + """ + return self._max_distance + + @max_distance.setter + def max_distance(self, value): + if value <= 0: + raise ValueError('invalid maximum distance (must be positive)') + t = self.threshold_distance + self._max_distance = value + self.threshold_distance = t + + @property + def threshold_distance(self): + """ + The distance, measured in meters, that will trigger the + :attr:`when_in_range` and :attr:`when_out_of_range` events when + crossed. This is simply a meter-scaled variant of the usual + :attr:`threshold` attribute. + """ + return self.threshold * self.max_distance + + @threshold_distance.setter + def threshold_distance(self, value): + self.threshold = value / self.max_distance + + @property + def distance(self): + """ + Returns the current distance measured by the sensor in meters. Note + that this property will have a value between 0 and + :attr:`max_distance`. + """ + return self.value * self._max_distance + + @property + def trigger(self): + """ + Returns the :class:`Pin` that the sensor's trigger is connected to. + """ + return self._trigger.pin + + @property + def echo(self): + """ + Returns the :class:`Pin` that the sensor's echo is connected to. This + is simply an alias for the usual :attr:`pin` attribute. + """ + return self.pin + + def _echo_changed(self): + if self._echo_rise is None: + self._echo_rise = time() + else: + self._echo_fall = time() + self._echo.set() + + def _read(self): + # Make sure the echo pin is low then ensure the echo event is clear + while self.pin.state: + sleep(0.00001) + self._echo.clear() + # Obtain ECHO_LOCK to ensure multiple distance sensors don't listen + # for each other's "pings" + with DistanceSensor.ECHO_LOCK: + # Fire the trigger + self._trigger.pin.state = True + sleep(0.00001) + self._trigger.pin.state = False + # Wait up to 1 second for the echo pin to rise + if self._echo.wait(1): + self._echo.clear() + # Wait up to 40ms for the echo pin to fall (35ms is maximum + # pulse time so any longer means something's gone wrong). + # Calculate distance as time for echo multiplied by speed of + # sound divided by two to compensate for travel to and from the + # reflector + if self._echo.wait(0.04) and self._echo_fall is not None and self._echo_rise is not None: + distance = (self._echo_fall - self._echo_rise) * self.speed_of_sound / 2.0 + self._echo_fall = None + self._echo_rise = None + return min(1.0, distance / self._max_distance) + else: + # If we only saw one edge it means we missed the echo + # because it was too fast; report minimum distance + return 0.0 + else: + # The echo pin never rose or fell; something's gone horribly + # wrong + warnings.warn(DistanceSensorNoEcho('no echo received')) + return 1.0 + + @property + def in_range(self): + return not self.is_active + +DistanceSensor.when_out_of_range = DistanceSensor.when_activated +DistanceSensor.when_in_range = DistanceSensor.when_deactivated +DistanceSensor.wait_for_out_of_range = DistanceSensor.wait_for_active +DistanceSensor.wait_for_in_range = DistanceSensor.wait_for_inactive + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/mixins.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/mixins.py new file mode 100644 index 00000000..7dc3e311 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/mixins.py @@ -0,0 +1,513 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, + ) +nstr = str +str = type('') + +import inspect +import weakref +from functools import wraps, partial +from threading import Event +from collections import deque +from time import time +try: + from statistics import median +except ImportError: + from .compat import median + +from .threads import GPIOThread +from .exc import ( + BadEventHandler, + BadWaitTime, + BadQueueLen, + DeviceClosed, + ) + +class ValuesMixin(object): + """ + Adds a :attr:`values` property to the class which returns an infinite + generator of readings from the :attr:`value` property. There is rarely a + need to use this mixin directly as all base classes in GPIO Zero include + it. + + .. note:: + + Use this mixin *first* in the parent class list. + """ + + @property + def values(self): + """ + An infinite iterator of values read from `value`. + """ + while True: + try: + yield self.value + except DeviceClosed: + break + + +class SourceMixin(object): + """ + Adds a :attr:`source` property to the class which, given an iterable, sets + :attr:`value` to each member of that iterable until it is exhausted. This + mixin is generally included in novel output devices to allow their state to + be driven from another device. + + .. note:: + + Use this mixin *first* in the parent class list. + """ + + def __init__(self, *args, **kwargs): + self._source = None + self._source_thread = None + self._source_delay = 0.01 + super(SourceMixin, self).__init__(*args, **kwargs) + + def close(self): + try: + self.source = None + except AttributeError: + pass + try: + super(SourceMixin, self).close() + except AttributeError: + pass + + def _copy_values(self, source): + for v in source: + self.value = v + if self._source_thread.stopping.wait(self._source_delay): + break + + @property + def source_delay(self): + """ + The delay (measured in seconds) in the loop used to read values from + :attr:`source`. Defaults to 0.01 seconds which is generally sufficient + to keep CPU usage to a minimum while providing adequate responsiveness. + """ + return self._source_delay + + @source_delay.setter + def source_delay(self, value): + if value < 0: + raise BadWaitTime('source_delay must be 0 or greater') + self._source_delay = float(value) + + @property + def source(self): + """ + The iterable to use as a source of values for :attr:`value`. + """ + return self._source + + @source.setter + def source(self, value): + if getattr(self, '_source_thread', None): + self._source_thread.stop() + self._source_thread = None + self._source = value + if value is not None: + self._source_thread = GPIOThread(target=self._copy_values, args=(value,)) + self._source_thread.start() + + +class SharedMixin(object): + """ + This mixin marks a class as "shared". In this case, the meta-class + (GPIOMeta) will use :meth:`_shared_key` to convert the constructor + arguments to an immutable key, and will check whether any existing + instances match that key. If they do, they will be returned by the + constructor instead of a new instance. An internal reference counter is + used to determine how many times an instance has been "constructed" in this + way. + + When :meth:`close` is called, an internal reference counter will be + decremented and the instance will only close when it reaches zero. + """ + _instances = {} + + def __del__(self): + self._refs = 0 + super(SharedMixin, self).__del__() + + @classmethod + def _shared_key(cls, *args, **kwargs): + """ + Given the constructor arguments, returns an immutable key representing + the instance. The default simply assumes all positional arguments are + immutable. + """ + return args + + +class EventsMixin(object): + """ + Adds edge-detected :meth:`when_activated` and :meth:`when_deactivated` + events to a device based on changes to the :attr:`~Device.is_active` + property common to all devices. Also adds :meth:`wait_for_active` and + :meth:`wait_for_inactive` methods for level-waiting. + + .. note:: + + Note that this mixin provides no means of actually firing its events; + call :meth:`_fire_events` in sub-classes when device state changes to + trigger the events. This should also be called once at the end of + initialization to set initial states. + """ + def __init__(self, *args, **kwargs): + super(EventsMixin, self).__init__(*args, **kwargs) + self._active_event = Event() + self._inactive_event = Event() + self._when_activated = None + self._when_deactivated = None + self._last_state = None + self._last_changed = time() + + def wait_for_active(self, timeout=None): + """ + Pause the script until the device is activated, or the timeout is + reached. + + :param float timeout: + Number of seconds to wait before proceeding. If this is ``None`` + (the default), then wait indefinitely until the device is active. + """ + return self._active_event.wait(timeout) + + def wait_for_inactive(self, timeout=None): + """ + Pause the script until the device is deactivated, or the timeout is + reached. + + :param float timeout: + Number of seconds to wait before proceeding. If this is ``None`` + (the default), then wait indefinitely until the device is inactive. + """ + return self._inactive_event.wait(timeout) + + @property + def when_activated(self): + """ + The function to run when the device changes state from inactive to + active. + + This can be set to a function which accepts no (mandatory) parameters, + or a Python function which accepts a single mandatory parameter (with + as many optional parameters as you like). If the function accepts a + single mandatory parameter, the device that activated will be passed + as that parameter. + + Set this property to ``None`` (the default) to disable the event. + """ + return self._when_activated + + @when_activated.setter + def when_activated(self, value): + self._when_activated = self._wrap_callback(value) + + @property + def when_deactivated(self): + """ + The function to run when the device changes state from active to + inactive. + + This can be set to a function which accepts no (mandatory) parameters, + or a Python function which accepts a single mandatory parameter (with + as many optional parameters as you like). If the function accepts a + single mandatory parameter, the device that deactivated will be + passed as that parameter. + + Set this property to ``None`` (the default) to disable the event. + """ + return self._when_deactivated + + @when_deactivated.setter + def when_deactivated(self, value): + self._when_deactivated = self._wrap_callback(value) + + @property + def active_time(self): + """ + The length of time (in seconds) that the device has been active for. + When the device is inactive, this is ``None``. + """ + if self._active_event.is_set(): + return time() - self._last_changed + else: + return None + + @property + def inactive_time(self): + """ + The length of time (in seconds) that the device has been inactive for. + When the device is active, this is ``None``. + """ + if self._inactive_event.is_set(): + return time() - self._last_changed + else: + return None + + def _wrap_callback(self, fn): + if fn is None: + return None + elif not callable(fn): + raise BadEventHandler('value must be None or a callable') + # If fn is wrapped with partial (i.e. partial, partialmethod, or wraps + # has been used to produce it) we need to dig out the "real" function + # that's been wrapped along with all the mandatory positional args + # used in the wrapper so we can test the binding + args = () + wrapped_fn = fn + while isinstance(wrapped_fn, partial): + args = wrapped_fn.args + args + wrapped_fn = wrapped_fn.func + if inspect.isbuiltin(wrapped_fn): + # We can't introspect the prototype of builtins. In this case we + # assume that the builtin has no (mandatory) parameters; this is + # the most reasonable assumption on the basis that pre-existing + # builtins have no knowledge of gpiozero, and the sole parameter + # we would pass is a gpiozero object + return fn + else: + # Try binding ourselves to the argspec of the provided callable. + # If this works, assume the function is capable of accepting no + # parameters + try: + inspect.getcallargs(wrapped_fn, *args) + return fn + except TypeError: + try: + # If the above fails, try binding with a single parameter + # (ourselves). If this works, wrap the specified callback + inspect.getcallargs(wrapped_fn, *(args + (self,))) + @wraps(fn) + def wrapper(): + return fn(self) + return wrapper + except TypeError: + raise BadEventHandler( + 'value must be a callable which accepts up to one ' + 'mandatory parameter') + + def _fire_activated(self): + # These methods are largely here to be overridden by descendents + if self.when_activated: + self.when_activated() + + def _fire_deactivated(self): + # These methods are largely here to be overridden by descendents + if self.when_deactivated: + self.when_deactivated() + + def _fire_events(self): + old_state = self._last_state + new_state = self._last_state = self.is_active + if old_state is None: + # Initial "indeterminate" state; set events but don't fire + # callbacks as there's not necessarily an edge + if new_state: + self._active_event.set() + else: + self._inactive_event.set() + elif old_state != new_state: + self._last_changed = time() + if new_state: + self._inactive_event.clear() + self._active_event.set() + self._fire_activated() + else: + self._active_event.clear() + self._inactive_event.set() + self._fire_deactivated() + + +class HoldMixin(EventsMixin): + """ + Extends :class:`EventsMixin` to add the :attr:`when_held` event and the + machinery to fire that event repeatedly (when :attr:`hold_repeat` is + ``True``) at internals defined by :attr:`hold_time`. + """ + def __init__(self, *args, **kwargs): + self._hold_thread = None + super(HoldMixin, self).__init__(*args, **kwargs) + self._when_held = None + self._held_from = None + self._hold_time = 1 + self._hold_repeat = False + self._hold_thread = HoldThread(self) + + def close(self): + if getattr(self, '_hold_thread', None): + self._hold_thread.stop() + self._hold_thread = None + try: + super(HoldMixin, self).close() + except AttributeError: + pass + + def _fire_activated(self): + super(HoldMixin, self)._fire_activated() + self._hold_thread.holding.set() + + def _fire_deactivated(self): + self._held_from = None + super(HoldMixin, self)._fire_deactivated() + + def _fire_held(self): + if self.when_held: + self.when_held() + + @property + def when_held(self): + """ + The function to run when the device has remained active for + :attr:`hold_time` seconds. + + This can be set to a function which accepts no (mandatory) parameters, + or a Python function which accepts a single mandatory parameter (with + as many optional parameters as you like). If the function accepts a + single mandatory parameter, the device that activated will be passed + as that parameter. + + Set this property to ``None`` (the default) to disable the event. + """ + return self._when_held + + @when_held.setter + def when_held(self, value): + self._when_held = self._wrap_callback(value) + + @property + def hold_time(self): + """ + The length of time (in seconds) to wait after the device is activated, + until executing the :attr:`when_held` handler. If :attr:`hold_repeat` + is True, this is also the length of time between invocations of + :attr:`when_held`. + """ + return self._hold_time + + @hold_time.setter + def hold_time(self, value): + if value < 0: + raise BadWaitTime('hold_time must be 0 or greater') + self._hold_time = float(value) + + @property + def hold_repeat(self): + """ + If ``True``, :attr:`when_held` will be executed repeatedly with + :attr:`hold_time` seconds between each invocation. + """ + return self._hold_repeat + + @hold_repeat.setter + def hold_repeat(self, value): + self._hold_repeat = bool(value) + + @property + def is_held(self): + """ + When ``True``, the device has been active for at least + :attr:`hold_time` seconds. + """ + return self._held_from is not None + + @property + def held_time(self): + """ + The length of time (in seconds) that the device has been held for. + This is counted from the first execution of the :attr:`when_held` event + rather than when the device activated, in contrast to + :attr:`~EventsMixin.active_time`. If the device is not currently held, + this is ``None``. + """ + if self._held_from is not None: + return time() - self._held_from + else: + return None + + +class HoldThread(GPIOThread): + """ + Extends :class:`GPIOThread`. Provides a background thread that repeatedly + fires the :attr:`HoldMixin.when_held` event as long as the owning + device is active. + """ + def __init__(self, parent): + super(HoldThread, self).__init__( + target=self.held, args=(weakref.proxy(parent),)) + self.holding = Event() + self.start() + + def held(self, parent): + try: + while not self.stopping.is_set(): + if self.holding.wait(0.1): + self.holding.clear() + while not ( + self.stopping.is_set() or + parent._inactive_event.wait(parent.hold_time) + ): + if parent._held_from is None: + parent._held_from = time() + parent._fire_held() + if not parent.hold_repeat: + break + except ReferenceError: + # Parent is dead; time to die! + pass + + +class GPIOQueue(GPIOThread): + """ + Extends :class:`GPIOThread`. Provides a background thread that monitors a + device's values and provides a running *average* (defaults to median) of + those values. If the *parent* device includes the :class:`EventsMixin` in + its ancestry, the thread automatically calls + :meth:`~EventsMixin._fire_events`. + """ + def __init__( + self, parent, queue_len=5, sample_wait=0.0, partial=False, + average=median): + assert callable(average) + super(GPIOQueue, self).__init__(target=self.fill) + if queue_len < 1: + raise BadQueueLen('queue_len must be at least one') + if sample_wait < 0: + raise BadWaitTime('sample_wait must be 0 or greater') + self.queue = deque(maxlen=queue_len) + self.partial = bool(partial) + self.sample_wait = float(sample_wait) + self.full = Event() + self.parent = weakref.proxy(parent) + self.average = average + + @property + def value(self): + if not self.partial: + self.full.wait() + try: + return self.average(self.queue) + except ZeroDivisionError: + # No data == inactive value + return 0.0 + + def fill(self): + try: + while not self.stopping.wait(self.sample_wait): + self.queue.append(self.parent._read()) + if not self.full.is_set() and len(self.queue) >= self.queue.maxlen: + self.full.set() + if (self.partial or self.full.is_set()) and isinstance(self.parent, EventsMixin): + self.parent._fire_events() + except ReferenceError: + # Parent is dead; time to die! + pass + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/other_devices.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/other_devices.py new file mode 100644 index 00000000..ea31802f --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/other_devices.py @@ -0,0 +1,249 @@ +# vim: set fileencoding=utf-8: + +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, +) +str = type('') + + +import os +import io +import subprocess +from datetime import datetime, time + +from .devices import Device +from .mixins import EventsMixin + + +class InternalDevice(EventsMixin, Device): + """ + Extends :class:`Device` to provide a basis for devices which have no + specific hardware representation. These are effectively pseudo-devices and + usually represent operating system services like the internal clock, file + systems or network facilities. + """ + + +class PingServer(InternalDevice): + """ + Extends :class:`InternalDevice` to provide a device which is active when a + *host* on the network can be pinged. + + The following example lights an LED while a server is reachable (note the + use of :attr:`~SourceMixin.source_delay` to ensure the server is not + flooded with pings):: + + from gpiozero import PingServer, LED + from signal import pause + + google = PingServer('google.com') + led = LED(4) + + led.source_delay = 60 # check once per minute + led.source = google.values + + pause() + + :param str host: + The hostname or IP address to attempt to ping. + """ + def __init__(self, host): + self.host = host + super(PingServer, self).__init__() + self._fire_events() + + def __repr__(self): + return '' % self.host + + @property + def value(self): + # XXX This is doing a DNS lookup every time it's queried; should we + # call gethostbyname in the constructor and ping that instead (good + # for consistency, but what if the user *expects* the host to change + # address?) + with io.open(os.devnull, 'wb') as devnull: + try: + subprocess.check_call( + ['ping', '-c1', self.host], + stdout=devnull, stderr=devnull) + except subprocess.CalledProcessError: + return False + else: + return True + + +class CPUTemperature(InternalDevice): + """ + Extends :class:`InternalDevice` to provide a device which is active when + the CPU temperature exceeds the *threshold* value. + + The following example plots the CPU's temperature on an LED bar graph:: + + from gpiozero import LEDBarGraph, CPUTemperature + from signal import pause + + # Use minimums and maximums that are closer to "normal" usage so the + # bar graph is a bit more "lively" + cpu = CPUTemperature(min_temp=50, max_temp=90) + + print('Initial temperature: {}C'.format(cpu.temperature)) + + graph = LEDBarGraph(5, 6, 13, 19, 25, pwm=True) + graph.source = cpu.values + + pause() + + :param str sensor_file: + The file from which to read the temperature. This defaults to the + sysfs file :file:`/sys/class/thermal/thermal_zone0/temp`. Whatever + file is specified is expected to contain a single line containing the + temperature in milli-degrees celsius. + + :param float min_temp: + The temperature at which :attr:`value` will read 0.0. This defaults to + 0.0. + + :param float max_temp: + The temperature at which :attr:`value` will read 1.0. This defaults to + 100.0. + + :param float threshold: + The temperature above which the device will be considered "active". + This defaults to 80.0. + """ + def __init__(self, sensor_file='/sys/class/thermal/thermal_zone0/temp', + min_temp=0.0, max_temp=100.0, threshold=80.0): + self.sensor_file = sensor_file + super(CPUTemperature, self).__init__() + self.min_temp = min_temp + self.max_temp = max_temp + self.threshold = threshold + self._fire_events() + + def __repr__(self): + return '' % self.temperature + + @property + def temperature(self): + """ + Returns the current CPU temperature in degrees celsius. + """ + with io.open(self.sensor_file, 'r') as f: + return float(f.readline().strip()) / 1000 + + @property + def value(self): + """ + Returns the current CPU temperature as a value between 0.0 + (representing the *min_temp* value) and 1.0 (representing the + *max_temp* value). These default to 0.0 and 100.0 respectively, hence + :attr:`value` is :attr:`temperature` divided by 100 by default. + """ + temp_range = self.max_temp - self.min_temp + return (self.temperature - self.min_temp) / temp_range + + @property + def is_active(self): + """ + Returns ``True`` when the CPU :attr:`temperature` exceeds the + :attr:`threshold`. + """ + return self.temperature > self.threshold + + +class TimeOfDay(InternalDevice): + """ + Extends :class:`InternalDevice` to provide a device which is active when + the computer's clock indicates that the current time is between + *start_time* and *end_time* (inclusive) which are :class:`~datetime.time` + instances. + + The following example turns on a lamp attached to an :class:`Energenie` + plug between 7 and 8 AM:: + + from gpiozero import TimeOfDay, Energenie + from datetime import time + from signal import pause + + lamp = Energenie(0) + morning = TimeOfDay(time(7), time(8)) + + lamp.source = morning.values + + pause() + + :param ~datetime.time start_time: + The time from which the device will be considered active. + + :param ~datetime.time end_time: + The time after which the device will be considered inactive. + + :param bool utc: + If ``True`` (the default), a naive UTC time will be used for the + comparison rather than a local time-zone reading. + """ + def __init__(self, start_time, end_time, utc=True): + self._start_time = None + self._end_time = None + self._utc = True + super(TimeOfDay, self).__init__() + self.start_time = start_time + self.end_time = end_time + self.utc = utc + self._fire_events() + + def __repr__(self): + return '' % ( + self.start_time, self.end_time, ('local', 'UTC')[self.utc]) + + @property + def start_time(self): + """ + The time of day after which the device will be considered active. + """ + return self._start_time + + @start_time.setter + def start_time(self, value): + if isinstance(value, datetime): + value = value.time() + if not isinstance(value, time): + raise ValueError('start_time must be a datetime, or time instance') + self._start_time = value + + @property + def end_time(self): + """ + The time of day after which the device will be considered inactive. + """ + return self._end_time + + @end_time.setter + def end_time(self, value): + if isinstance(value, datetime): + value = value.time() + if not isinstance(value, time): + raise ValueError('end_time must be a datetime, or time instance') + self._end_time = value + + @property + def utc(self): + """ + If ``True``, use a naive UTC time reading for comparison instead of a + local timezone reading. + """ + return self._utc + + @utc.setter + def utc(self, value): + self._utc = bool(value) + + @property + def value(self): + if self.utc: + return self.start_time <= datetime.utcnow().time() <= self.end_time + else: + return self.start_time <= datetime.now().time() <= self.end_time diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/output_devices.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/output_devices.py new file mode 100644 index 00000000..4036c6db --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/output_devices.py @@ -0,0 +1,1397 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, +) + +from threading import Lock +from itertools import repeat, cycle, chain + +from .exc import OutputDeviceBadValue, GPIOPinMissing +from .devices import GPIODevice, Device, CompositeDevice +from .mixins import SourceMixin +from .threads import GPIOThread + + +class OutputDevice(SourceMixin, GPIODevice): + """ + Represents a generic GPIO output device. + + This class extends :class:`GPIODevice` to add facilities common to GPIO + output devices: an :meth:`on` method to switch the device on, a + corresponding :meth:`off` method, and a :meth:`toggle` method. + + :param int pin: + The GPIO pin (in BCM numbering) that the device is connected to. If + this is ``None`` a :exc:`GPIOPinMissing` will be raised. + + :param bool active_high: + If ``True`` (the default), the :meth:`on` method will set the GPIO to + HIGH. If ``False``, the :meth:`on` method will set the GPIO to LOW (the + :meth:`off` method always does the opposite). + + :param bool initial_value: + If ``False`` (the default), the device will be off initially. If + ``None``, the device will be left in whatever state the pin is found in + when configured for output (warning: this can be on). If ``True``, the + device will be switched on initially. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, pin=None, active_high=True, initial_value=False, + pin_factory=None): + super(OutputDevice, self).__init__(pin, pin_factory=pin_factory) + self._lock = Lock() + self.active_high = active_high + if initial_value is None: + self.pin.function = 'output' + else: + self.pin.output_with_state(self._value_to_state(initial_value)) + + def _value_to_state(self, value): + return bool(self._active_state if value else self._inactive_state) + + def _write(self, value): + try: + self.pin.state = self._value_to_state(value) + except AttributeError: + self._check_open() + raise + + def on(self): + """ + Turns the device on. + """ + self._write(True) + + def off(self): + """ + Turns the device off. + """ + self._write(False) + + def toggle(self): + """ + Reverse the state of the device. If it's on, turn it off; if it's off, + turn it on. + """ + with self._lock: + if self.is_active: + self.off() + else: + self.on() + + @property + def value(self): + """ + Returns ``True`` if the device is currently active and ``False`` + otherwise. Setting this property changes the state of the device. + """ + return super(OutputDevice, self).value + + @value.setter + def value(self, value): + self._write(value) + + @property + def active_high(self): + """ + When ``True``, the :attr:`value` property is ``True`` when the device's + :attr:`pin` is high. When ``False`` the :attr:`value` property is + ``True`` when the device's pin is low (i.e. the value is inverted). + + This property can be set after construction; be warned that changing it + will invert :attr:`value` (i.e. changing this property doesn't change + the device's pin state - it just changes how that state is + interpreted). + """ + return self._active_state + + @active_high.setter + def active_high(self, value): + self._active_state = True if value else False + self._inactive_state = False if value else True + + def __repr__(self): + try: + return '' % ( + self.__class__.__name__, self.pin, self.active_high, self.is_active) + except: + return super(OutputDevice, self).__repr__() + + +class DigitalOutputDevice(OutputDevice): + """ + Represents a generic output device with typical on/off behaviour. + + This class extends :class:`OutputDevice` with a :meth:`blink` method which + uses an optional background thread to handle toggling the device state + without further interaction. + """ + def __init__( + self, pin=None, active_high=True, initial_value=False, + pin_factory=None): + self._blink_thread = None + self._controller = None + super(DigitalOutputDevice, self).__init__( + pin, active_high, initial_value, pin_factory=pin_factory + ) + + @property + def value(self): + return self._read() + + @value.setter + def value(self, value): + self._stop_blink() + self._write(value) + + def close(self): + self._stop_blink() + super(DigitalOutputDevice, self).close() + + def on(self): + self._stop_blink() + self._write(True) + + def off(self): + self._stop_blink() + self._write(False) + + def blink(self, on_time=1, off_time=1, n=None, background=True): + """ + Make the device turn on and off repeatedly. + + :param float on_time: + Number of seconds on. Defaults to 1 second. + + :param float off_time: + Number of seconds off. Defaults to 1 second. + + :param int n: + Number of times to blink; ``None`` (the default) means forever. + + :param bool background: + If ``True`` (the default), start a background thread to continue + blinking and return immediately. If ``False``, only return when the + blink is finished (warning: the default value of *n* will result in + this method never returning). + """ + self._stop_blink() + self._blink_thread = GPIOThread( + target=self._blink_device, args=(on_time, off_time, n) + ) + self._blink_thread.start() + if not background: + self._blink_thread.join() + self._blink_thread = None + + def _stop_blink(self): + if getattr(self, '_controller', None): + self._controller._stop_blink(self) + self._controller = None + if getattr(self, '_blink_thread', None): + self._blink_thread.stop() + self._blink_thread = None + + def _blink_device(self, on_time, off_time, n): + iterable = repeat(0) if n is None else repeat(0, n) + for _ in iterable: + self._write(True) + if self._blink_thread.stopping.wait(on_time): + break + self._write(False) + if self._blink_thread.stopping.wait(off_time): + break + + +class LED(DigitalOutputDevice): + """ + Extends :class:`DigitalOutputDevice` and represents a light emitting diode + (LED). + + Connect the cathode (short leg, flat side) of the LED to a ground pin; + connect the anode (longer leg) to a limiting resistor; connect the other + side of the limiting resistor to a GPIO pin (the limiting resistor can be + placed either side of the LED). + + The following example will light the LED:: + + from gpiozero import LED + + led = LED(17) + led.on() + + :param int pin: + The GPIO pin which the LED is attached to. See :ref:`pin-numbering` for + valid pin numbers. + + :param bool active_high: + If ``True`` (the default), the LED will operate normally with the + circuit described above. If ``False`` you should wire the cathode to + the GPIO pin, and the anode to a 3V3 pin (via a limiting resistor). + + :param bool initial_value: + If ``False`` (the default), the LED will be off initially. If + ``None``, the LED will be left in whatever state the pin is found in + when configured for output (warning: this can be on). If ``True``, the + LED will be switched on initially. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + pass + +LED.is_lit = LED.is_active + + +class Buzzer(DigitalOutputDevice): + """ + Extends :class:`DigitalOutputDevice` and represents a digital buzzer + component. + + Connect the cathode (negative pin) of the buzzer to a ground pin; connect + the other side to any GPIO pin. + + The following example will sound the buzzer:: + + from gpiozero import Buzzer + + bz = Buzzer(3) + bz.on() + + :param int pin: + The GPIO pin which the buzzer is attached to. See :ref:`pin-numbering` + for valid pin numbers. + + :param bool active_high: + If ``True`` (the default), the buzzer will operate normally with the + circuit described above. If ``False`` you should wire the cathode to + the GPIO pin, and the anode to a 3V3 pin. + + :param bool initial_value: + If ``False`` (the default), the buzzer will be silent initially. If + ``None``, the buzzer will be left in whatever state the pin is found in + when configured for output (warning: this can be on). If ``True``, the + buzzer will be switched on initially. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + pass + +Buzzer.beep = Buzzer.blink + + +class PWMOutputDevice(OutputDevice): + """ + Generic output device configured for pulse-width modulation (PWM). + + :param int pin: + The GPIO pin which the device is attached to. See :ref:`pin-numbering` + for valid pin numbers. + + :param bool active_high: + If ``True`` (the default), the :meth:`on` method will set the GPIO to + HIGH. If ``False``, the :meth:`on` method will set the GPIO to LOW (the + :meth:`off` method always does the opposite). + + :param float initial_value: + If ``0`` (the default), the device's duty cycle will be 0 initially. + Other values between 0 and 1 can be specified as an initial duty cycle. + Note that ``None`` cannot be specified (unlike the parent class) as + there is no way to tell PWM not to alter the state of the pin. + + :param int frequency: + The frequency (in Hz) of pulses emitted to drive the device. Defaults + to 100Hz. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, pin=None, active_high=True, initial_value=0, frequency=100, + pin_factory=None): + self._blink_thread = None + self._controller = None + if not 0 <= initial_value <= 1: + raise OutputDeviceBadValue("initial_value must be between 0 and 1") + super(PWMOutputDevice, self).__init__( + pin, active_high, initial_value=None, pin_factory=pin_factory + ) + try: + # XXX need a way of setting these together + self.pin.frequency = frequency + self.value = initial_value + except: + self.close() + raise + + def close(self): + try: + self._stop_blink() + except AttributeError: + pass + try: + self.pin.frequency = None + except AttributeError: + # If the pin's already None, ignore the exception + pass + super(PWMOutputDevice, self).close() + + def _state_to_value(self, state): + return float(state if self.active_high else 1 - state) + + def _value_to_state(self, value): + return float(value if self.active_high else 1 - value) + + def _write(self, value): + if not 0 <= value <= 1: + raise OutputDeviceBadValue("PWM value must be between 0 and 1") + super(PWMOutputDevice, self)._write(value) + + @property + def value(self): + """ + The duty cycle of the PWM device. 0.0 is off, 1.0 is fully on. Values + in between may be specified for varying levels of power in the device. + """ + return self._read() + + @value.setter + def value(self, value): + self._stop_blink() + self._write(value) + + def on(self): + self._stop_blink() + self._write(1) + + def off(self): + self._stop_blink() + self._write(0) + + def toggle(self): + """ + Toggle the state of the device. If the device is currently off + (:attr:`value` is 0.0), this changes it to "fully" on (:attr:`value` is + 1.0). If the device has a duty cycle (:attr:`value`) of 0.1, this will + toggle it to 0.9, and so on. + """ + self._stop_blink() + self.value = 1 - self.value + + @property + def is_active(self): + """ + Returns ``True`` if the device is currently active (:attr:`value` is + non-zero) and ``False`` otherwise. + """ + return self.value != 0 + + @property + def frequency(self): + """ + The frequency of the pulses used with the PWM device, in Hz. The + default is 100Hz. + """ + return self.pin.frequency + + @frequency.setter + def frequency(self, value): + self.pin.frequency = value + + def blink( + self, on_time=1, off_time=1, fade_in_time=0, fade_out_time=0, + n=None, background=True): + """ + Make the device turn on and off repeatedly. + + :param float on_time: + Number of seconds on. Defaults to 1 second. + + :param float off_time: + Number of seconds off. Defaults to 1 second. + + :param float fade_in_time: + Number of seconds to spend fading in. Defaults to 0. + + :param float fade_out_time: + Number of seconds to spend fading out. Defaults to 0. + + :param int n: + Number of times to blink; ``None`` (the default) means forever. + + :param bool background: + If ``True`` (the default), start a background thread to continue + blinking and return immediately. If ``False``, only return when the + blink is finished (warning: the default value of *n* will result in + this method never returning). + """ + self._stop_blink() + self._blink_thread = GPIOThread( + target=self._blink_device, + args=(on_time, off_time, fade_in_time, fade_out_time, n) + ) + self._blink_thread.start() + if not background: + self._blink_thread.join() + self._blink_thread = None + + def pulse(self, fade_in_time=1, fade_out_time=1, n=None, background=True): + """ + Make the device fade in and out repeatedly. + + :param float fade_in_time: + Number of seconds to spend fading in. Defaults to 1. + + :param float fade_out_time: + Number of seconds to spend fading out. Defaults to 1. + + :param int n: + Number of times to pulse; ``None`` (the default) means forever. + + :param bool background: + If ``True`` (the default), start a background thread to continue + pulsing and return immediately. If ``False``, only return when the + pulse is finished (warning: the default value of *n* will result in + this method never returning). + """ + on_time = off_time = 0 + self.blink( + on_time, off_time, fade_in_time, fade_out_time, n, background + ) + + def _stop_blink(self): + if self._controller: + self._controller._stop_blink(self) + self._controller = None + if self._blink_thread: + self._blink_thread.stop() + self._blink_thread = None + + def _blink_device( + self, on_time, off_time, fade_in_time, fade_out_time, n, fps=25): + sequence = [] + if fade_in_time > 0: + sequence += [ + (i * (1 / fps) / fade_in_time, 1 / fps) + for i in range(int(fps * fade_in_time)) + ] + sequence.append((1, on_time)) + if fade_out_time > 0: + sequence += [ + (1 - (i * (1 / fps) / fade_out_time), 1 / fps) + for i in range(int(fps * fade_out_time)) + ] + sequence.append((0, off_time)) + sequence = ( + cycle(sequence) if n is None else + chain.from_iterable(repeat(sequence, n)) + ) + for value, delay in sequence: + self._write(value) + if self._blink_thread.stopping.wait(delay): + break + + +class PWMLED(PWMOutputDevice): + """ + Extends :class:`PWMOutputDevice` and represents a light emitting diode + (LED) with variable brightness. + + A typical configuration of such a device is to connect a GPIO pin to the + anode (long leg) of the LED, and the cathode (short leg) to ground, with + an optional resistor to prevent the LED from burning out. + + :param int pin: + The GPIO pin which the LED is attached to. See :ref:`pin-numbering` for + valid pin numbers. + + :param bool active_high: + If ``True`` (the default), the :meth:`on` method will set the GPIO to + HIGH. If ``False``, the :meth:`on` method will set the GPIO to LOW (the + :meth:`off` method always does the opposite). + + :param float initial_value: + If ``0`` (the default), the LED will be off initially. Other values + between 0 and 1 can be specified as an initial brightness for the LED. + Note that ``None`` cannot be specified (unlike the parent class) as + there is no way to tell PWM not to alter the state of the pin. + + :param int frequency: + The frequency (in Hz) of pulses emitted to drive the LED. Defaults + to 100Hz. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + pass + +PWMLED.is_lit = PWMLED.is_active + + +def _led_property(index, doc=None): + def getter(self): + return self._leds[index].value + def setter(self, value): + self._stop_blink() + self._leds[index].value = value + return property(getter, setter, doc=doc) + + +class RGBLED(SourceMixin, Device): + """ + Extends :class:`Device` and represents a full color LED component (composed + of red, green, and blue LEDs). + + Connect the common cathode (longest leg) to a ground pin; connect each of + the other legs (representing the red, green, and blue anodes) to any GPIO + pins. You can either use three limiting resistors (one per anode) or a + single limiting resistor on the cathode. + + The following code will make the LED purple:: + + from gpiozero import RGBLED + + led = RGBLED(2, 3, 4) + led.color = (1, 0, 1) + + :param int red: + The GPIO pin that controls the red component of the RGB LED. + + :param int green: + The GPIO pin that controls the green component of the RGB LED. + + :param int blue: + The GPIO pin that controls the blue component of the RGB LED. + + :param bool active_high: + Set to ``True`` (the default) for common cathode RGB LEDs. If you are + using a common anode RGB LED, set this to ``False``. + + :param tuple initial_value: + The initial color for the RGB LED. Defaults to black ``(0, 0, 0)``. + + :param bool pwm: + If ``True`` (the default), construct :class:`PWMLED` instances for + each component of the RGBLED. If ``False``, construct regular + :class:`LED` instances, which prevents smooth color graduations. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, red=None, green=None, blue=None, active_high=True, + initial_value=(0, 0, 0), pwm=True, pin_factory=None): + self._leds = () + self._blink_thread = None + if not all(p is not None for p in [red, green, blue]): + raise GPIOPinMissing('red, green, and blue pins must be provided') + LEDClass = PWMLED if pwm else LED + super(RGBLED, self).__init__(pin_factory=pin_factory) + self._leds = tuple( + LEDClass(pin, active_high, pin_factory=pin_factory) + for pin in (red, green, blue) + ) + self.value = initial_value + + red = _led_property(0) + green = _led_property(1) + blue = _led_property(2) + + def close(self): + if getattr(self, '_leds', None): + self._stop_blink() + for led in self._leds: + led.close() + self._leds = () + super(RGBLED, self).close() + + @property + def closed(self): + return len(self._leds) == 0 + + @property + def value(self): + """ + Represents the color of the LED as an RGB 3-tuple of ``(red, green, + blue)`` where each value is between 0 and 1 if ``pwm`` was ``True`` + when the class was constructed (and only 0 or 1 if not). + + For example, purple would be ``(1, 0, 1)`` and yellow would be ``(1, 1, + 0)``, while orange would be ``(1, 0.5, 0)``. + """ + return (self.red, self.green, self.blue) + + @value.setter + def value(self, value): + for component in value: + if not 0 <= component <= 1: + raise OutputDeviceBadValue('each RGB color component must be between 0 and 1') + if isinstance(self._leds[0], LED): + if component not in (0, 1): + raise OutputDeviceBadValue('each RGB color component must be 0 or 1 with non-PWM RGBLEDs') + self._stop_blink() + self.red, self.green, self.blue = value + + @property + def is_active(self): + """ + Returns ``True`` if the LED is currently active (not black) and + ``False`` otherwise. + """ + return self.value != (0, 0, 0) + + is_lit = is_active + color = value + + def on(self): + """ + Turn the LED on. This equivalent to setting the LED color to white + ``(1, 1, 1)``. + """ + self.value = (1, 1, 1) + + def off(self): + """ + Turn the LED off. This is equivalent to setting the LED color to black + ``(0, 0, 0)``. + """ + self.value = (0, 0, 0) + + def toggle(self): + """ + Toggle the state of the device. If the device is currently off + (:attr:`value` is ``(0, 0, 0)``), this changes it to "fully" on + (:attr:`value` is ``(1, 1, 1)``). If the device has a specific color, + this method inverts the color. + """ + r, g, b = self.value + self.value = (1 - r, 1 - g, 1 - b) + + def blink( + self, on_time=1, off_time=1, fade_in_time=0, fade_out_time=0, + on_color=(1, 1, 1), off_color=(0, 0, 0), n=None, background=True): + """ + Make the device turn on and off repeatedly. + + :param float on_time: + Number of seconds on. Defaults to 1 second. + + :param float off_time: + Number of seconds off. Defaults to 1 second. + + :param float fade_in_time: + Number of seconds to spend fading in. Defaults to 0. Must be 0 if + ``pwm`` was ``False`` when the class was constructed + (:exc:`ValueError` will be raised if not). + + :param float fade_out_time: + Number of seconds to spend fading out. Defaults to 0. Must be 0 if + ``pwm`` was ``False`` when the class was constructed + (:exc:`ValueError` will be raised if not). + + :param tuple on_color: + The color to use when the LED is "on". Defaults to white. + + :param tuple off_color: + The color to use when the LED is "off". Defaults to black. + + :param int n: + Number of times to blink; ``None`` (the default) means forever. + + :param bool background: + If ``True`` (the default), start a background thread to continue + blinking and return immediately. If ``False``, only return when the + blink is finished (warning: the default value of *n* will result in + this method never returning). + """ + if isinstance(self._leds[0], LED): + if fade_in_time: + raise ValueError('fade_in_time must be 0 with non-PWM RGBLEDs') + if fade_out_time: + raise ValueError('fade_out_time must be 0 with non-PWM RGBLEDs') + self._stop_blink() + self._blink_thread = GPIOThread( + target=self._blink_device, + args=( + on_time, off_time, fade_in_time, fade_out_time, + on_color, off_color, n + ) + ) + self._blink_thread.start() + if not background: + self._blink_thread.join() + self._blink_thread = None + + def pulse( + self, fade_in_time=1, fade_out_time=1, + on_color=(1, 1, 1), off_color=(0, 0, 0), n=None, background=True): + """ + Make the device fade in and out repeatedly. + + :param float fade_in_time: + Number of seconds to spend fading in. Defaults to 1. + + :param float fade_out_time: + Number of seconds to spend fading out. Defaults to 1. + + :param tuple on_color: + The color to use when the LED is "on". Defaults to white. + + :param tuple off_color: + The color to use when the LED is "off". Defaults to black. + + :param int n: + Number of times to pulse; ``None`` (the default) means forever. + + :param bool background: + If ``True`` (the default), start a background thread to continue + pulsing and return immediately. If ``False``, only return when the + pulse is finished (warning: the default value of *n* will result in + this method never returning). + """ + on_time = off_time = 0 + self.blink( + on_time, off_time, fade_in_time, fade_out_time, + on_color, off_color, n, background + ) + + def _stop_blink(self, led=None): + # If this is called with a single led, we stop all blinking anyway + if self._blink_thread: + self._blink_thread.stop() + self._blink_thread = None + + def _blink_device( + self, on_time, off_time, fade_in_time, fade_out_time, on_color, + off_color, n, fps=25): + # Define some simple lambdas to perform linear interpolation between + # off_color and on_color + lerp = lambda t, fade_in: tuple( + (1 - t) * off + t * on + if fade_in else + (1 - t) * on + t * off + for off, on in zip(off_color, on_color) + ) + sequence = [] + if fade_in_time > 0: + sequence += [ + (lerp(i * (1 / fps) / fade_in_time, True), 1 / fps) + for i in range(int(fps * fade_in_time)) + ] + sequence.append((on_color, on_time)) + if fade_out_time > 0: + sequence += [ + (lerp(i * (1 / fps) / fade_out_time, False), 1 / fps) + for i in range(int(fps * fade_out_time)) + ] + sequence.append((off_color, off_time)) + sequence = ( + cycle(sequence) if n is None else + chain.from_iterable(repeat(sequence, n)) + ) + for l in self._leds: + l._controller = self + for value, delay in sequence: + for l, v in zip(self._leds, value): + l._write(v) + if self._blink_thread.stopping.wait(delay): + break + + +class Motor(SourceMixin, CompositeDevice): + """ + Extends :class:`CompositeDevice` and represents a generic motor + connected to a bi-directional motor driver circuit (i.e. an `H-bridge`_). + + Attach an `H-bridge`_ motor controller to your Pi; connect a power source + (e.g. a battery pack or the 5V pin) to the controller; connect the outputs + of the controller board to the two terminals of the motor; connect the + inputs of the controller board to two GPIO pins. + + .. _H-bridge: https://en.wikipedia.org/wiki/H_bridge + + The following code will make the motor turn "forwards":: + + from gpiozero import Motor + + motor = Motor(17, 18) + motor.forward() + + :param int forward: + The GPIO pin that the forward input of the motor driver chip is + connected to. + + :param int backward: + The GPIO pin that the backward input of the motor driver chip is + connected to. + + :param bool pwm: + If ``True`` (the default), construct :class:`PWMOutputDevice` + instances for the motor controller pins, allowing both direction and + variable speed control. If ``False``, construct + :class:`DigitalOutputDevice` instances, allowing only direction + control. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__(self, forward=None, backward=None, pwm=True, pin_factory=None): + if not all(p is not None for p in [forward, backward]): + raise GPIOPinMissing( + 'forward and backward pins must be provided' + ) + PinClass = PWMOutputDevice if pwm else DigitalOutputDevice + super(Motor, self).__init__( + forward_device=PinClass(forward, pin_factory=pin_factory), + backward_device=PinClass(backward, pin_factory=pin_factory), + _order=('forward_device', 'backward_device'), + pin_factory=pin_factory + ) + + @property + def value(self): + """ + Represents the speed of the motor as a floating point value between -1 + (full speed backward) and 1 (full speed forward), with 0 representing + stopped. + """ + return self.forward_device.value - self.backward_device.value + + @value.setter + def value(self, value): + if not -1 <= value <= 1: + raise OutputDeviceBadValue("Motor value must be between -1 and 1") + if value > 0: + try: + self.forward(value) + except ValueError as e: + raise OutputDeviceBadValue(e) + elif value < 0: + try: + self.backward(-value) + except ValueError as e: + raise OutputDeviceBadValue(e) + else: + self.stop() + + @property + def is_active(self): + """ + Returns ``True`` if the motor is currently running and ``False`` + otherwise. + """ + return self.value != 0 + + def forward(self, speed=1): + """ + Drive the motor forwards. + + :param float speed: + The speed at which the motor should turn. Can be any value between + 0 (stopped) and the default 1 (maximum speed) if ``pwm`` was + ``True`` when the class was constructed (and only 0 or 1 if not). + """ + if not 0 <= speed <= 1: + raise ValueError('forward speed must be between 0 and 1') + if isinstance(self.forward_device, DigitalOutputDevice): + if speed not in (0, 1): + raise ValueError('forward speed must be 0 or 1 with non-PWM Motors') + self.backward_device.off() + self.forward_device.value = speed + + def backward(self, speed=1): + """ + Drive the motor backwards. + + :param float speed: + The speed at which the motor should turn. Can be any value between + 0 (stopped) and the default 1 (maximum speed) if ``pwm`` was + ``True`` when the class was constructed (and only 0 or 1 if not). + """ + if not 0 <= speed <= 1: + raise ValueError('backward speed must be between 0 and 1') + if isinstance(self.backward_device, DigitalOutputDevice): + if speed not in (0, 1): + raise ValueError('backward speed must be 0 or 1 with non-PWM Motors') + self.forward_device.off() + self.backward_device.value = speed + + def reverse(self): + """ + Reverse the current direction of the motor. If the motor is currently + idle this does nothing. Otherwise, the motor's direction will be + reversed at the current speed. + """ + self.value = -self.value + + def stop(self): + """ + Stop the motor. + """ + self.forward_device.off() + self.backward_device.off() + + +class PhaseEnableMotor(SourceMixin, CompositeDevice): + """ + Extends :class:`CompositeDevice` and represents a generic motor connected + to a Phase/Enable motor driver circuit; the phase of the driver controls + whether the motor turns forwards or backwards, while enable controls the + speed with PWM. + + The following code will make the motor turn "forwards":: + + from gpiozero import PhaseEnableMotor + motor = PhaseEnableMotor(12, 5) + motor.forward() + + :param int phase: + The GPIO pin that the phase (direction) input of the motor driver chip + is connected to. + + :param int enable: + The GPIO pin that the enable (speed) input of the motor driver chip is + connected to. + + :param bool pwm: + If ``True`` (the default), construct :class:`PWMOutputDevice` + instances for the motor controller pins, allowing both direction and + variable speed control. If ``False``, construct + :class:`DigitalOutputDevice` instances, allowing only direction + control. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__(self, phase=None, enable=None, pwm=True, pin_factory=None): + if not all([phase, enable]): + raise GPIOPinMissing('phase and enable pins must be provided') + PinClass = PWMOutputDevice if pwm else DigitalOutputDevice + super(PhaseEnableMotor, self).__init__( + phase_device=OutputDevice(phase, pin_factory=pin_factory), + enable_device=PinClass(enable, pin_factory=pin_factory), + _order=('phase_device', 'enable_device'), + pin_factory=pin_factory + ) + + @property + def value(self): + """ + Represents the speed of the motor as a floating point value between -1 + (full speed backward) and 1 (full speed forward). + """ + return -self.enable_device.value if self.phase_device.is_active else self.enable_device.value + + @value.setter + def value(self, value): + if not -1 <= value <= 1: + raise OutputDeviceBadValue("Motor value must be between -1 and 1") + if value > 0: + self.forward(value) + elif value < 0: + self.backward(-value) + else: + self.stop() + + @property + def is_active(self): + """ + Returns ``True`` if the motor is currently running and ``False`` + otherwise. + """ + return self.value != 0 + + def forward(self, speed=1): + """ + Drive the motor forwards. + + :param float speed: + The speed at which the motor should turn. Can be any value between + 0 (stopped) and the default 1 (maximum speed). + """ + if isinstance(self.enable_device, DigitalOutputDevice): + if speed not in (0, 1): + raise ValueError('forward speed must be 0 or 1 with non-PWM Motors') + self.enable_device.off() + self.phase_device.off() + self.enable_device.value = speed + + def backward(self, speed=1): + """ + Drive the motor backwards. + + :param float speed: + The speed at which the motor should turn. Can be any value between + 0 (stopped) and the default 1 (maximum speed). + """ + if isinstance(self.enable_device, DigitalOutputDevice): + if speed not in (0, 1): + raise ValueError('backward speed must be 0 or 1 with non-PWM Motors') + self.enable_device.off() + self.phase_device.on() + self.enable_device.value = speed + + def reverse(self): + """ + Reverse the current direction of the motor. If the motor is currently + idle this does nothing. Otherwise, the motor's direction will be + reversed at the current speed. + """ + self.value = -self.value + + def stop(self): + """ + Stop the motor. + """ + self.enable_device.off() + + +class Servo(SourceMixin, CompositeDevice): + """ + Extends :class:`CompositeDevice` and represents a PWM-controlled servo + motor connected to a GPIO pin. + + Connect a power source (e.g. a battery pack or the 5V pin) to the power + cable of the servo (this is typically colored red); connect the ground + cable of the servo (typically colored black or brown) to the negative of + your battery pack, or a GND pin; connect the final cable (typically colored + white or orange) to the GPIO pin you wish to use for controlling the servo. + + The following code will make the servo move between its minimum, maximum, + and mid-point positions with a pause between each:: + + from gpiozero import Servo + from time import sleep + + servo = Servo(17) + while True: + servo.min() + sleep(1) + servo.mid() + sleep(1) + servo.max() + sleep(1) + + :param int pin: + The GPIO pin which the device is attached to. See :ref:`pin-numbering` + for valid pin numbers. + + :param float initial_value: + If ``0`` (the default), the device's mid-point will be set + initially. Other values between -1 and +1 can be specified as an + initial position. ``None`` means to start the servo un-controlled (see + :attr:`value`). + + :param float min_pulse_width: + The pulse width corresponding to the servo's minimum position. This + defaults to 1ms. + + :param float max_pulse_width: + The pulse width corresponding to the servo's maximum position. This + defaults to 2ms. + + :param float frame_width: + The length of time between servo control pulses measured in seconds. + This defaults to 20ms which is a common value for servos. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, pin=None, initial_value=0.0, + min_pulse_width=1/1000, max_pulse_width=2/1000, + frame_width=20/1000, pin_factory=None): + if min_pulse_width >= max_pulse_width: + raise ValueError('min_pulse_width must be less than max_pulse_width') + if max_pulse_width >= frame_width: + raise ValueError('max_pulse_width must be less than frame_width') + self._frame_width = frame_width + self._min_dc = min_pulse_width / frame_width + self._dc_range = (max_pulse_width - min_pulse_width) / frame_width + self._min_value = -1 + self._value_range = 2 + super(Servo, self).__init__( + pwm_device=PWMOutputDevice( + pin, frequency=int(1 / frame_width), pin_factory=pin_factory + ), + pin_factory=pin_factory + ) + try: + self.value = initial_value + except: + self.close() + raise + + @property + def frame_width(self): + """ + The time between control pulses, measured in seconds. + """ + return self._frame_width + + @property + def min_pulse_width(self): + """ + The control pulse width corresponding to the servo's minimum position, + measured in seconds. + """ + return self._min_dc * self.frame_width + + @property + def max_pulse_width(self): + """ + The control pulse width corresponding to the servo's maximum position, + measured in seconds. + """ + return (self._dc_range * self.frame_width) + self.min_pulse_width + + @property + def pulse_width(self): + """ + Returns the current pulse width controlling the servo. + """ + if self.pwm_device.pin.frequency is None: + return None + else: + return self.pwm_device.pin.state * self.frame_width + + def min(self): + """ + Set the servo to its minimum position. + """ + self.value = -1 + + def mid(self): + """ + Set the servo to its mid-point position. + """ + self.value = 0 + + def max(self): + """ + Set the servo to its maximum position. + """ + self.value = 1 + + def detach(self): + """ + Temporarily disable control of the servo. This is equivalent to + setting :attr:`value` to ``None``. + """ + self.value = None + + def _get_value(self): + if self.pwm_device.pin.frequency is None: + return None + else: + return ( + ((self.pwm_device.pin.state - self._min_dc) / self._dc_range) * + self._value_range + self._min_value) + + @property + def value(self): + """ + Represents the position of the servo as a value between -1 (the minimum + position) and +1 (the maximum position). This can also be the special + value ``None`` indicating that the servo is currently "uncontrolled", + i.e. that no control signal is being sent. Typically this means the + servo's position remains unchanged, but that it can be moved by hand. + """ + result = self._get_value() + if result is None: + return result + else: + # NOTE: This round() only exists to ensure we don't confuse people + # by returning 2.220446049250313e-16 as the default initial value + # instead of 0. The reason _get_value and _set_value are split + # out is for descendents that require the un-rounded values for + # accuracy + return round(result, 14) + + @value.setter + def value(self, value): + if value is None: + self.pwm_device.pin.frequency = None + elif -1 <= value <= 1: + self.pwm_device.pin.frequency = int(1 / self.frame_width) + self.pwm_device.pin.state = ( + self._min_dc + self._dc_range * + ((value - self._min_value) / self._value_range) + ) + else: + raise OutputDeviceBadValue( + "Servo value must be between -1 and 1, or None") + + @property + def is_active(self): + return self.value is not None + + +class AngularServo(Servo): + """ + Extends :class:`Servo` and represents a rotational PWM-controlled servo + motor which can be set to particular angles (assuming valid minimum and + maximum angles are provided to the constructor). + + Connect a power source (e.g. a battery pack or the 5V pin) to the power + cable of the servo (this is typically colored red); connect the ground + cable of the servo (typically colored black or brown) to the negative of + your battery pack, or a GND pin; connect the final cable (typically colored + white or orange) to the GPIO pin you wish to use for controlling the servo. + + Next, calibrate the angles that the servo can rotate to. In an interactive + Python session, construct a :class:`Servo` instance. The servo should move + to its mid-point by default. Set the servo to its minimum value, and + measure the angle from the mid-point. Set the servo to its maximum value, + and again measure the angle:: + + >>> from gpiozero import Servo + >>> s = Servo(17) + >>> s.min() # measure the angle + >>> s.max() # measure the angle + + You should now be able to construct an :class:`AngularServo` instance + with the correct bounds:: + + >>> from gpiozero import AngularServo + >>> s = AngularServo(17, min_angle=-42, max_angle=44) + >>> s.angle = 0.0 + >>> s.angle + 0.0 + >>> s.angle = 15 + >>> s.angle + 15.0 + + .. note:: + + You can set *min_angle* greater than *max_angle* if you wish to reverse + the sense of the angles (e.g. ``min_angle=45, max_angle=-45``). This + can be useful with servos that rotate in the opposite direction to your + expectations of minimum and maximum. + + :param int pin: + The GPIO pin which the device is attached to. See :ref:`pin-numbering` + for valid pin numbers. + + :param float initial_angle: + Sets the servo's initial angle to the specified value. The default is + 0. The value specified must be between *min_angle* and *max_angle* + inclusive. ``None`` means to start the servo un-controlled (see + :attr:`value`). + + :param float min_angle: + Sets the minimum angle that the servo can rotate to. This defaults to + -90, but should be set to whatever you measure from your servo during + calibration. + + :param float max_angle: + Sets the maximum angle that the servo can rotate to. This defaults to + 90, but should be set to whatever you measure from your servo during + calibration. + + :param float min_pulse_width: + The pulse width corresponding to the servo's minimum position. This + defaults to 1ms. + + :param float max_pulse_width: + The pulse width corresponding to the servo's maximum position. This + defaults to 2ms. + + :param float frame_width: + The length of time between servo control pulses measured in seconds. + This defaults to 20ms which is a common value for servos. + + :param Factory pin_factory: + See :doc:`api_pins` for more information (this is an advanced feature + which most users can ignore). + """ + def __init__( + self, pin=None, initial_angle=0.0, + min_angle=-90, max_angle=90, + min_pulse_width=1/1000, max_pulse_width=2/1000, + frame_width=20/1000, pin_factory=None): + self._min_angle = min_angle + self._angular_range = max_angle - min_angle + if initial_angle is None: + initial_value = None + elif ((min_angle <= initial_angle <= max_angle) or + (max_angle <= initial_angle <= min_angle)): + initial_value = 2 * ((initial_angle - min_angle) / self._angular_range) - 1 + else: + raise OutputDeviceBadValue( + "AngularServo angle must be between %s and %s, or None" % + (min_angle, max_angle)) + super(AngularServo, self).__init__( + pin, initial_value, min_pulse_width, max_pulse_width, frame_width, + pin_factory=pin_factory + ) + + @property + def min_angle(self): + """ + The minimum angle that the servo will rotate to when :meth:`min` is + called. + """ + return self._min_angle + + @property + def max_angle(self): + """ + The maximum angle that the servo will rotate to when :meth:`max` is + called. + """ + return self._min_angle + self._angular_range + + @property + def angle(self): + """ + The position of the servo as an angle measured in degrees. This will + only be accurate if *min_angle* and *max_angle* have been set + appropriately in the constructor. + + This can also be the special value ``None`` indicating that the servo + is currently "uncontrolled", i.e. that no control signal is being sent. + Typically this means the servo's position remains unchanged, but that + it can be moved by hand. + """ + result = self._get_value() + if result is None: + return None + else: + # NOTE: Why round(n, 12) here instead of 14? Angle ranges can be + # much larger than -1..1 so we need a little more rounding to + # smooth off the rough corners! + return round( + self._angular_range * + ((result - self._min_value) / self._value_range) + + self._min_angle, 12) + + @angle.setter + def angle(self, angle): + if angle is None: + self.value = None + elif ((self.min_angle <= angle <= self.max_angle) or + (self.max_angle <= angle <= self.min_angle)): + self.value = ( + self._value_range * + ((angle - self._min_angle) / self._angular_range) + + self._min_value) + else: + raise OutputDeviceBadValue( + "AngularServo angle must be between %s and %s, or None" % + (self.min_angle, self.max_angle)) diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/__init__.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/__init__.py new file mode 100644 index 00000000..fcb73720 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/__init__.py @@ -0,0 +1,697 @@ +# vim: set fileencoding=utf-8: + +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + +from weakref import ref +from collections import defaultdict +from threading import Lock + +from ..exc import ( + PinInvalidFunction, + PinSetInput, + PinFixedPull, + PinUnsupported, + PinSPIUnsupported, + PinPWMUnsupported, + PinEdgeDetectUnsupported, + SPIFixedClockMode, + SPIFixedBitOrder, + SPIFixedSelect, + SPIFixedWordSize, + GPIOPinInUse, + ) + + +class Factory(object): + """ + Generates pins and SPI interfaces for devices. This is an abstract + base class for pin factories. Descendents *may* override the following + methods, if applicable: + + * :meth:`close` + * :meth:`reserve_pins` + * :meth:`release_pins` + * :meth:`release_all` + * :meth:`pin` + * :meth:`spi` + * :meth:`_get_pi_info` + """ + def __init__(self): + self._reservations = defaultdict(list) + self._res_lock = Lock() + + def reserve_pins(self, requester, *pins): + """ + Called to indicate that the device reserves the right to use the + specified *pins*. This should be done during device construction. If + pins are reserved, you must ensure that the reservation is released by + eventually called :meth:`release_pins`. + """ + with self._res_lock: + for pin in pins: + for reserver_ref in self._reservations[pin]: + reserver = reserver_ref() + if reserver is not None and requester._conflicts_with(reserver): + raise GPIOPinInUse('pin %s is already in use by %r' % + (pin, reserver)) + self._reservations[pin].append(ref(requester)) + + def release_pins(self, reserver, *pins): + """ + Releases the reservation of *reserver* against *pins*. This is + typically called during :meth:`Device.close` to clean up reservations + taken during construction. Releasing a reservation that is not currently + held will be silently ignored (to permit clean-up after failed / partial + construction). + """ + with self._res_lock: + for pin in pins: + self._reservations[pin] = [ + ref for ref in self._reservations[pin] + if ref() not in (reserver, None) # may as well clean up dead refs + ] + + def release_all(self, reserver): + """ + Releases all pin reservations taken out by *reserver*. See + :meth:`release_pins` for further information). + """ + with self._res_lock: + self._reservations = defaultdict(list, { + pin: [ + ref for ref in conflictors + if ref() not in (reserver, None) + ] + for pin, conflictors in self._reservations.items() + }) + + def close(self): + """ + Closes the pin factory. This is expected to clean up all resources + manipulated by the factory. It it typically called at script + termination. + """ + pass + + def pin(self, spec): + """ + Creates an instance of a :class:`Pin` descendent representing the + specified pin. + + .. warning:: + + Descendents must ensure that pin instances representing the same + hardware are identical; i.e. two separate invocations of + :meth:`pin` for the same pin specification must return the same + object. + """ + raise PinUnsupported("Individual pins are not supported by this pin factory") + + def spi(self, **spi_args): + """ + Returns an instance of an :class:`SPI` interface, for the specified SPI + *port* and *device*, or for the specified pins (*clock_pin*, + *mosi_pin*, *miso_pin*, and *select_pin*). Only one of the schemes can + be used; attempting to mix *port* and *device* with pin numbers will + raise :exc:`SPIBadArgs`. + """ + raise PinSPIUnsupported('SPI not supported by this pin factory') + + def _get_pi_info(self): + return None + + pi_info = property( + lambda self: self._get_pi_info(), + doc="""\ + Returns a :class:`PiBoardInfo` instance representing the Pi that + instances generated by this factory will be attached to. + + If the pins represented by this class are not *directly* attached to a + Pi (e.g. the pin is attached to a board attached to the Pi, or the pins + are not on a Pi at all), this may return ``None``. + """) + + +class Pin(object): + """ + Abstract base class representing a pin attached to some form of controller, + be it GPIO, SPI, ADC, etc. + + Descendents should override property getters and setters to accurately + represent the capabilities of pins. Descendents *must* override the + following methods: + + * :meth:`_get_function` + * :meth:`_set_function` + * :meth:`_get_state` + + Descendents *may* additionally override the following methods, if + applicable: + + * :meth:`close` + * :meth:`output_with_state` + * :meth:`input_with_pull` + * :meth:`_set_state` + * :meth:`_get_frequency` + * :meth:`_set_frequency` + * :meth:`_get_pull` + * :meth:`_set_pull` + * :meth:`_get_bounce` + * :meth:`_set_bounce` + * :meth:`_get_edges` + * :meth:`_set_edges` + * :meth:`_get_when_changed` + * :meth:`_set_when_changed` + """ + + def __repr__(self): + return "" + + def close(self): + """ + Cleans up the resources allocated to the pin. After this method is + called, this :class:`Pin` instance may no longer be used to query or + control the pin's state. + """ + pass + + def output_with_state(self, state): + """ + Sets the pin's function to "output" and specifies an initial state + for the pin. By default this is equivalent to performing:: + + pin.function = 'output' + pin.state = state + + However, descendents may override this in order to provide the smallest + possible delay between configuring the pin for output and specifying an + initial value (which can be important for avoiding "blips" in + active-low configurations). + """ + self.function = 'output' + self.state = state + + def input_with_pull(self, pull): + """ + Sets the pin's function to "input" and specifies an initial pull-up + for the pin. By default this is equivalent to performing:: + + pin.function = 'input' + pin.pull = pull + + However, descendents may override this order to provide the smallest + possible delay between configuring the pin for input and pulling the + pin up/down (which can be important for avoiding "blips" in some + configurations). + """ + self.function = 'input' + self.pull = pull + + def _get_function(self): + return "input" + + def _set_function(self, value): + if value != "input": + raise PinInvalidFunction( + "Cannot set the function of pin %r to %s" % (self, value)) + + function = property( + lambda self: self._get_function(), + lambda self, value: self._set_function(value), + doc="""\ + The function of the pin. This property is a string indicating the + current function or purpose of the pin. Typically this is the string + "input" or "output". However, in some circumstances it can be other + strings indicating non-GPIO related functionality. + + With certain pin types (e.g. GPIO pins), this attribute can be changed + to configure the function of a pin. If an invalid function is + specified, for this attribute, :exc:`PinInvalidFunction` will be + raised. + """) + + def _get_state(self): + return 0 + + def _set_state(self, value): + raise PinSetInput("Cannot set the state of input pin %r" % self) + + state = property( + lambda self: self._get_state(), + lambda self, value: self._set_state(value), + doc="""\ + The state of the pin. This is 0 for low, and 1 for high. As a low level + view of the pin, no swapping is performed in the case of pull ups (see + :attr:`pull` for more information): + + .. code-block:: text + + HIGH - - - - > ,---------------------- + | + | + LOW ----------------' + + Descendents which implement analog, or analog-like capabilities can + return values between 0 and 1. For example, pins implementing PWM + (where :attr:`frequency` is not ``None``) return a value between 0.0 + and 1.0 representing the current PWM duty cycle. + + If a pin is currently configured for input, and an attempt is made to + set this attribute, :exc:`PinSetInput` will be raised. If an invalid + value is specified for this attribute, :exc:`PinInvalidState` will be + raised. + """) + + def _get_pull(self): + return 'floating' + + def _set_pull(self, value): + raise PinFixedPull("Cannot change pull-up on pin %r" % self) + + pull = property( + lambda self: self._get_pull(), + lambda self, value: self._set_pull(value), + doc="""\ + The pull-up state of the pin represented as a string. This is typically + one of the strings "up", "down", or "floating" but additional values + may be supported by the underlying hardware. + + If the pin does not support changing pull-up state (for example because + of a fixed pull-up resistor), attempts to set this property will raise + :exc:`PinFixedPull`. If the specified value is not supported by the + underlying hardware, :exc:`PinInvalidPull` is raised. + """) + + def _get_frequency(self): + return None + + def _set_frequency(self, value): + if value is not None: + raise PinPWMUnsupported("PWM is not supported on pin %r" % self) + + frequency = property( + lambda self: self._get_frequency(), + lambda self, value: self._set_frequency(value), + doc="""\ + The frequency (in Hz) for the pin's PWM implementation, or ``None`` if + PWM is not currently in use. This value always defaults to ``None`` and + may be changed with certain pin types to activate or deactivate PWM. + + If the pin does not support PWM, :exc:`PinPWMUnsupported` will be + raised when attempting to set this to a value other than ``None``. + """) + + def _get_bounce(self): + return None + + def _set_bounce(self, value): + if value is not None: + raise PinEdgeDetectUnsupported("Edge detection is not supported on pin %r" % self) + + bounce = property( + lambda self: self._get_bounce(), + lambda self, value: self._set_bounce(value), + doc="""\ + The amount of bounce detection (elimination) currently in use by edge + detection, measured in seconds. If bounce detection is not currently in + use, this is ``None``. + + For example, if :attr:`edges` is currently "rising", :attr:`bounce` is + currently 5/1000 (5ms), then the waveform below will only fire + :attr:`when_changed` on two occasions despite there being three rising + edges: + + .. code-block:: text + + TIME 0...1...2...3...4...5...6...7...8...9...10..11..12 ms + + bounce elimination |===================| |============== + + HIGH - - - - > ,--. ,--------------. ,--. + | | | | | | + | | | | | | + LOW ----------------' `-' `-' `----------- + : : + : : + when_changed when_changed + fires fires + + If the pin does not support edge detection, attempts to set this + property will raise :exc:`PinEdgeDetectUnsupported`. If the pin + supports edge detection, the class must implement bounce detection, + even if only in software. + """) + + def _get_edges(self): + return 'none' + + def _set_edges(self, value): + raise PinEdgeDetectUnsupported("Edge detection is not supported on pin %r" % self) + + edges = property( + lambda self: self._get_edges(), + lambda self, value: self._set_edges(value), + doc="""\ + The edge that will trigger execution of the function or bound method + assigned to :attr:`when_changed`. This can be one of the strings + "both" (the default), "rising", "falling", or "none": + + .. code-block:: text + + HIGH - - - - > ,--------------. + | | + | | + LOW --------------------' `-------------- + : : + : : + Fires when_changed "both" "both" + when edges is ... "rising" "falling" + + If the pin does not support edge detection, attempts to set this + property will raise :exc:`PinEdgeDetectUnsupported`. + """) + + def _get_when_changed(self): + return None + + def _set_when_changed(self, value): + raise PinEdgeDetectUnsupported("Edge detection is not supported on pin %r" % self) + + when_changed = property( + lambda self: self._get_when_changed(), + lambda self, value: self._set_when_changed(value), + doc="""\ + A function or bound method to be called when the pin's state changes + (more specifically when the edge specified by :attr:`edges` is detected + on the pin). The function or bound method must take no parameters. + + If the pin does not support edge detection, attempts to set this + property will raise :exc:`PinEdgeDetectUnsupported`. + """) + + +class SPI(object): + """ + Abstract interface for `Serial Peripheral Interface`_ (SPI) + implementations. Descendents *must* override the following methods: + + * :meth:`transfer` + * :meth:`_get_clock_mode` + + Descendents *may* override the following methods, if applicable: + + * :meth:`read` + * :meth:`write` + * :meth:`_set_clock_mode` + * :meth:`_get_lsb_first` + * :meth:`_set_lsb_first` + * :meth:`_get_select_high` + * :meth:`_set_select_high` + * :meth:`_get_bits_per_word` + * :meth:`_set_bits_per_word` + + .. _Serial Peripheral Interface: https://en.wikipedia.org/wiki/Serial_Peripheral_Interface_Bus + """ + + def read(self, n): + """ + Read *n* words of data from the SPI interface, returning them as a + sequence of unsigned ints, each no larger than the configured + :attr:`bits_per_word` of the interface. + + This method is typically used with read-only devices that feature + half-duplex communication. See :meth:`transfer` for full duplex + communication. + """ + return self.transfer((0,) * n) + + def write(self, data): + """ + Write *data* to the SPI interface. *data* must be a sequence of + unsigned integer words each of which will fit within the configured + :attr:`bits_per_word` of the interface. The method returns the number + of words written to the interface (which may be less than or equal to + the length of *data*). + + This method is typically used with write-only devices that feature + half-duplex communication. See :meth:`transfer` for full duplex + communication. + """ + return len(self.transfer(data)) + + def transfer(self, data): + """ + Write *data* to the SPI interface. *data* must be a sequence of + unsigned integer words each of which will fit within the configured + :attr:`bits_per_word` of the interface. The method returns the sequence + of words read from the interface while writing occurred (full duplex + communication). + + The length of the sequence returned dictates the number of words of + *data* written to the interface. Each word in the returned sequence + will be an unsigned integer no larger than the configured + :attr:`bits_per_word` of the interface. + """ + raise NotImplementedError + + @property + def clock_polarity(self): + """ + The polarity of the SPI clock pin. If this is ``False`` (the default), + the clock pin will idle low, and pulse high. Setting this to ``True`` + will cause the clock pin to idle high, and pulse low. On many data + sheets this is documented as the CPOL value. + + The following diagram illustrates the waveform when + :attr:`clock_polarity` is ``False`` (the default), equivalent to CPOL + 0: + + .. code-block:: text + + on on on on on on on + ,---. ,---. ,---. ,---. ,---. ,---. ,---. + CLK | | | | | | | | | | | | | | + | | | | | | | | | | | | | | + ------' `---' `---' `---' `---' `---' `---' `------ + idle off off off off off off idle + + The following diagram illustrates the waveform when + :attr:`clock_polarity` is ``True``, equivalent to CPOL 1: + + .. code-block:: text + + idle off off off off off off idle + ------. ,---. ,---. ,---. ,---. ,---. ,---. ,------ + | | | | | | | | | | | | | | + CLK | | | | | | | | | | | | | | + `---' `---' `---' `---' `---' `---' `---' + on on on on on on on + """ + return bool(self.clock_mode & 2) + + @clock_polarity.setter + def clock_polarity(self, value): + self.clock_mode = self.clock_mode & (~2) | (bool(value) << 1) + + @property + def clock_phase(self): + """ + The phase of the SPI clock pin. If this is ``False`` (the default), + data will be read from the MISO pin when the clock pin activates. + Setting this to ``True`` will cause data to be read from the MISO pin + when the clock pin deactivates. On many data sheets this is documented + as the CPHA value. Whether the clock edge is rising or falling when the + clock is considered activated is controlled by the + :attr:`clock_polarity` attribute (corresponding to CPOL). + + The following diagram indicates when data is read when + :attr:`clock_polarity` is ``False``, and :attr:`clock_phase` is + ``False`` (the default), equivalent to CPHA 0: + + .. code-block:: text + + ,---. ,---. ,---. ,---. ,---. ,---. ,---. + CLK | | | | | | | | | | | | | | + | | | | | | | | | | | | | | + ----' `---' `---' `---' `---' `---' `---' `------- + : : : : : : : + MISO---. ,---. ,---. ,---. ,---. ,---. ,---. + / \ / \ / \ / \ / \ / \ / \\ + -{ Bit X Bit X Bit X Bit X Bit X Bit X Bit }------ + \ / \ / \ / \ / \ / \ / \ / + `---' `---' `---' `---' `---' `---' `---' + + The following diagram indicates when data is read when + :attr:`clock_polarity` is ``False``, but :attr:`clock_phase` is + ``True``, equivalent to CPHA 1: + + .. code-block:: text + + ,---. ,---. ,---. ,---. ,---. ,---. ,---. + CLK | | | | | | | | | | | | | | + | | | | | | | | | | | | | | + ----' `---' `---' `---' `---' `---' `---' `------- + : : : : : : : + MISO ,---. ,---. ,---. ,---. ,---. ,---. ,---. + / \ / \ / \ / \ / \ / \ / \\ + -----{ Bit X Bit X Bit X Bit X Bit X Bit X Bit }-- + \ / \ / \ / \ / \ / \ / \ / + `---' `---' `---' `---' `---' `---' `---' + """ + return bool(self.clock_mode & 1) + + @clock_phase.setter + def clock_phase(self, value): + self.clock_mode = self.clock_mode & (~1) | bool(value) + + def _get_clock_mode(self): + raise NotImplementedError + + def _set_clock_mode(self, value): + raise SPIFixedClockMode("clock_mode cannot be changed on %r" % self) + + clock_mode = property( + lambda self: self._get_clock_mode(), + lambda self, value: self._set_clock_mode(value), + doc="""\ + Presents a value representing the :attr:`clock_polarity` and + :attr:`clock_phase` attributes combined according to the following + table: + + +------+-----------------+--------------+ + | mode | polarity (CPOL) | phase (CPHA) | + +======+=================+==============+ + | 0 | False | False | + +------+-----------------+--------------+ + | 1 | False | True | + +------+-----------------+--------------+ + | 2 | True | False | + +------+-----------------+--------------+ + | 3 | True | True | + +------+-----------------+--------------+ + + Adjusting this value adjusts both the :attr:`clock_polarity` and + :attr:`clock_phase` attributes simultaneously. + """) + + def _get_lsb_first(self): + return False + + def _set_lsb_first(self, value): + raise SPIFixedBitOrder("lsb_first cannot be changed on %r" % self) + + lsb_first = property( + lambda self: self._get_lsb_first(), + lambda self, value: self._set_lsb_first(value), + doc="""\ + Controls whether words are read and written LSB in (Least Significant + Bit first) order. The default is ``False`` indicating that words are + read and written in MSB (Most Significant Bit first) order. + Effectively, this controls the `Bit endianness`_ of the connection. + + The following diagram shows the a word containing the number 5 (binary + 0101) transmitted on MISO with :attr:`bits_per_word` set to 4, and + :attr:`clock_mode` set to 0, when :attr:`lsb_first` is ``False`` (the + default): + + .. code-block:: text + + ,---. ,---. ,---. ,---. + CLK | | | | | | | | + | | | | | | | | + ----' `---' `---' `---' `----- + : ,-------. : ,-------. + MISO: | : | : | : | + : | : | : | : | + ----------' : `-------' : `---- + : : : : + MSB LSB + + And now with :attr:`lsb_first` set to ``True`` (and all other + parameters the same): + + .. code-block:: text + + ,---. ,---. ,---. ,---. + CLK | | | | | | | | + | | | | | | | | + ----' `---' `---' `---' `----- + ,-------. : ,-------. : + MISO: | : | : | : + | : | : | : | : + --' : `-------' : `----------- + : : : : + LSB MSB + + .. _Bit endianness: https://en.wikipedia.org/wiki/Endianness#Bit_endianness + """) + + def _get_select_high(self): + return False + + def _set_select_high(self, value): + raise SPIFixedSelect("select_high cannot be changed on %r" % self) + + select_high = property( + lambda self: self._get_select_high(), + lambda self, value: self._set_select_high(value), + doc="""\ + If ``False`` (the default), the chip select line is considered active + when it is pulled low. When set to ``True``, the chip select line is + considered active when it is driven high. + + The following diagram shows the waveform of the chip select line, and + the clock when :attr:`clock_polarity` is ``False``, and + :attr:`select_high` is ``False`` (the default): + + .. code-block:: text + + ---. ,------ + __ | | + CS | chip is selected, and will react to clock | idle + `-----------------------------------------------------' + + ,---. ,---. ,---. ,---. ,---. ,---. ,---. + CLK | | | | | | | | | | | | | | + | | | | | | | | | | | | | | + ----' `---' `---' `---' `---' `---' `---' `------- + + And when :attr:`select_high` is ``True``: + + .. code-block:: text + + ,-----------------------------------------------------. + CS | chip is selected, and will react to clock | idle + | | + ---' `------ + + ,---. ,---. ,---. ,---. ,---. ,---. ,---. + CLK | | | | | | | | | | | | | | + | | | | | | | | | | | | | | + ----' `---' `---' `---' `---' `---' `---' `------- + """) + + def _get_bits_per_word(self): + return 8 + + def _set_bits_per_word(self, value): + raise SPIFixedWordSize("bits_per_word cannot be changed on %r" % self) + + bits_per_word = property( + lambda self: self._get_bits_per_word(), + lambda self, value: self._set_bits_per_word(value), + doc="""\ + Controls the number of bits that make up a word, and thus where the + word boundaries appear in the data stream, and the maximum value of a + word. Defaults to 8 meaning that words are effectively bytes. + + Several implementations do not support non-byte-sized words. + """) + + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/data.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/data.py new file mode 100644 index 00000000..25e33fab --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/data.py @@ -0,0 +1,1157 @@ +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + +import os +import sys +from textwrap import dedent +from itertools import cycle +from operator import attrgetter +from collections import namedtuple + +from ..exc import PinUnknownPi, PinMultiplePins, PinNoPins, PinInvalidPin + + +# Some useful constants for describing pins + +V1_8 = '1V8' +V3_3 = '3V3' +V5 = '5V' +GND = 'GND' +NC = 'NC' # not connected +GPIO0 = 'GPIO0' +GPIO1 = 'GPIO1' +GPIO2 = 'GPIO2' +GPIO3 = 'GPIO3' +GPIO4 = 'GPIO4' +GPIO5 = 'GPIO5' +GPIO6 = 'GPIO6' +GPIO7 = 'GPIO7' +GPIO8 = 'GPIO8' +GPIO9 = 'GPIO9' +GPIO10 = 'GPIO10' +GPIO11 = 'GPIO11' +GPIO12 = 'GPIO12' +GPIO13 = 'GPIO13' +GPIO14 = 'GPIO14' +GPIO15 = 'GPIO15' +GPIO16 = 'GPIO16' +GPIO17 = 'GPIO17' +GPIO18 = 'GPIO18' +GPIO19 = 'GPIO19' +GPIO20 = 'GPIO20' +GPIO21 = 'GPIO21' +GPIO22 = 'GPIO22' +GPIO23 = 'GPIO23' +GPIO24 = 'GPIO24' +GPIO25 = 'GPIO25' +GPIO26 = 'GPIO26' +GPIO27 = 'GPIO27' +GPIO28 = 'GPIO28' +GPIO29 = 'GPIO29' +GPIO30 = 'GPIO30' +GPIO31 = 'GPIO31' +GPIO32 = 'GPIO32' +GPIO33 = 'GPIO33' +GPIO34 = 'GPIO34' +GPIO35 = 'GPIO35' +GPIO36 = 'GPIO36' +GPIO37 = 'GPIO37' +GPIO38 = 'GPIO38' +GPIO39 = 'GPIO39' +GPIO40 = 'GPIO40' +GPIO41 = 'GPIO41' +GPIO42 = 'GPIO42' +GPIO43 = 'GPIO43' +GPIO44 = 'GPIO44' +GPIO45 = 'GPIO45' + +# Board layout ASCII art + +REV1_BOARD = """\ +{style:white on green}+------------------{style:black on white}| |{style:white on green}--{style:on cyan}| |{style:on green}------+{style:reset} +{style:white on green}| {P1:{style} col2}{style:white on green} P1 {style:black on yellow}|C|{style:white on green} {style:on cyan}|A|{style:on green} |{style:reset} +{style:white on green}| {P1:{style} col1}{style:white on green} {style:black on yellow}+-+{style:white on green} {style:on cyan}+-+{style:on green} |{style:reset} +{style:white on green}| |{style:reset} +{style:white on green}| {style:on black}+---+{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|SoC|{style:on green} {style:black on white}| USB{style:reset} +{style:white on green}| {style:on black}|D|{style:on green} {style:bold}Pi Model{style:normal} {style:on black}+---+{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|S|{style:on green} {style:bold}{model:3s}V{pcb_revision:3s}{style:normal} |{style:reset} +{style:white on green}| {style:on black}|I|{style:on green} {style:on black}|C|{style:black on white}+======{style:reset} +{style:white on green}| {style:on black}|S|{style:black on white}| Net{style:reset} +{style:white on green}| {style:on black}|I|{style:black on white}+======{style:reset} +{style:black on white}=pwr{style:on green} {style:on white}|HDMI|{style:white on green} |{style:reset} +{style:white on green}+----------------{style:black on white}| |{style:white on green}----------+{style:reset}""" + +REV2_BOARD = """\ +{style:white on green}+------------------{style:black on white}| |{style:white on green}--{style:on cyan}| |{style:on green}------+{style:reset} +{style:white on green}| {P1:{style} col2}{style:white on green} P1 {style:black on yellow}|C|{style:white on green} {style:on cyan}|A|{style:on green} |{style:reset} +{style:white on green}| {P1:{style} col1}{style:white on green} {style:black on yellow}+-+{style:white on green} {style:on cyan}+-+{style:on green} |{style:reset} +{style:white on green}| {P5:{style} col1}{style:white on green} |{style:reset} +{style:white on green}| P5 {P5:{style} col2}{style:white on green} {style:on black}+---+{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|SoC|{style:on green} {style:black on white}| USB{style:reset} +{style:white on green}| {style:on black}|D|{style:on green} {style:bold}Pi Model{style:normal} {style:on black}+---+{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|S|{style:on green} {style:bold}{model:3s}V{pcb_revision:3s}{style:normal} |{style:reset} +{style:white on green}| {style:on black}|I|{style:on green} {style:on black}|C|{style:black on white}+======{style:reset} +{style:white on green}| {style:on black}|S|{style:black on white}| Net{style:reset} +{style:white on green}| {style:on black}|I|{style:black on white}+======{style:reset} +{style:black on white}=pwr{style:on green} {style:on white}|HDMI|{style:white on green} |{style:reset} +{style:white on green}+----------------{style:black on white}| |{style:white on green}----------+{style:reset}""" + +A_BOARD = """\ +{style:white on green}+------------------{style:black on white}| |{style:white on green}--{style:on cyan}| |{style:on green}------+{style:reset} +{style:white on green}| {P1:{style} col2}{style:white on green} P1 {style:black on yellow}|C|{style:white on green} {style:on cyan}|A|{style:on green} |{style:reset} +{style:white on green}| {P1:{style} col1}{style:white on green} {style:black on yellow}+-+{style:white on green} {style:on cyan}+-+{style:on green} |{style:reset} +{style:white on green}| {P5:{style} col1}{style:white on green} |{style:reset} +{style:white on green}| P5 {P5:{style} col2}{style:white on green} {style:on black}+---+{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|SoC|{style:on green} {style:black on white}| USB{style:reset} +{style:white on green}| {style:on black}|D|{style:on green} {style:bold}Pi Model{style:normal} {style:on black}+---+{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|S|{style:on green} {style:bold}{model:3s}V{pcb_revision:3s}{style:normal} |{style:reset} +{style:white on green}| {style:on black}|I|{style:on green} {style:on black}|C|{style:on green} |{style:reset} +{style:white on green}| {style:on black}|S|{style:on green} |{style:reset} +{style:white on green}| {style:on black}|I|{style:on green} |{style:reset} +{style:black on white}=pwr{style:on green} {style:on white}|HDMI|{style:white on green} |{style:reset} +{style:white on green}+----------------{style:black on white}| |{style:white on green}----------+{style:reset}""" + +BPLUS_BOARD = """\ +{style:white on green},--------------------------------.{style:reset} +{style:white on green}| {J8:{style} col2}{style:white on green} J8 {style:black on white}+===={style:reset} +{style:white on green}| {J8:{style} col1}{style:white on green} {style:black on white}| USB{style:reset} +{style:white on green}| {style:black on white}+===={style:reset} +{style:white on green}| {style:bold}Pi Model {model:3s}V{pcb_revision:3s}{style:normal} |{style:reset} +{style:white on green}| {style:on black}+----+{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|D|{style:on green} {style:on black}|SoC |{style:on green} {style:black on white}| USB{style:reset} +{style:white on green}| {style:on black}|S|{style:on green} {style:on black}| |{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|I|{style:on green} {style:on black}+----+{style:on green} |{style:reset} +{style:white on green}| {style:on black}|C|{style:on green} {style:black on white}+======{style:reset} +{style:white on green}| {style:on black}|S|{style:on green} {style:black on white}| Net{style:reset} +{style:white on green}| {style:black on white}pwr{style:white on green} {style:black on white}|HDMI|{style:white on green} {style:on black}|I||A|{style:on green} {style:black on white}+======{style:reset} +{style:white on green}`-{style:black on white}| |{style:white on green}--------{style:black on white}| |{style:white on green}----{style:on black}|V|{style:on green}-------'{style:reset}""" + +APLUS_BOARD = """\ +{style:white on green},--------------------------.{style:reset} +{style:white on green}| {J8:{style} col2}{style:white on green} J8 |{style:reset} +{style:white on green}| {J8:{style} col1}{style:white on green} |{style:reset} +{style:white on green}| |{style:reset} +{style:white on green}| {style:bold}Pi Model {model:3s}V{pcb_revision:3s}{style:normal} |{style:reset} +{style:white on green}| {style:on black}+----+{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|D|{style:on green} {style:on black}|SoC |{style:on green} {style:black on white}| USB{style:reset} +{style:white on green}| {style:on black}|S|{style:on green} {style:on black}| |{style:on green} {style:black on white}+===={style:reset} +{style:white on green}| {style:on black}|I|{style:on green} {style:on black}+----+{style:on green} |{style:reset} +{style:white on green}| {style:on black}|C|{style:on green} |{style:reset} +{style:white on green}| {style:on black}|S|{style:on green} |{style:reset} +{style:white on green}| {style:black on white}pwr{style:white on green} {style:black on white}|HDMI|{style:white on green} {style:on black}|I||A|{style:on green} |{style:reset} +{style:white on green}`-{style:black on white}| |{style:white on green}--------{style:black on white}| |{style:white on green}----{style:on black}|V|{style:on green}-'{style:reset}""" + +ZERO12_BOARD = """\ +{style:white on green},-------------------------.{style:reset} +{style:white on green}| {J8:{style} col2}{style:white on green} J8 |{style:reset} +{style:white on green}| {J8:{style} col1}{style:white on green} |{style:reset} +{style:black on white}---+{style:white on green} {style:on black}+---+{style:on green} {style:bold}PiZero{style:normal} |{style:reset} +{style:black on white} sd|{style:white on green} {style:on black}|SoC|{style:on green} {style:bold}V{pcb_revision:3s}{style:normal} |{style:reset} +{style:black on white}---+|hdmi|{style:white on green} {style:on black}+---+{style:on green} {style:black on white}usb{style:on green} {style:black on white}pwr{style:white on green} |{style:reset} +{style:white on green}`---{style:black on white}| |{style:white on green}--------{style:black on white}| |{style:white on green}-{style:black on white}| |{style:white on green}-'{style:reset}""" + +ZERO13_BOARD = """\ +{style:white on green}.-------------------------.{style:reset} +{style:white on green}| {J8:{style} col2}{style:white on green} J8 |{style:reset} +{style:white on green}| {J8:{style} col1}{style:white on green} {style:black on white}|c{style:reset} +{style:black on white}---+{style:white on green} {style:on black}+---+{style:on green} {style:bold}Pi{model:6s}{style:normal}{style:black on white}|s{style:reset} +{style:black on white} sd|{style:white on green} {style:on black}|SoC|{style:on green} {style:bold}V{pcb_revision:3s}{style:normal} {style:black on white}|i{style:reset} +{style:black on white}---+|hdmi|{style:white on green} {style:on black}+---+{style:on green} {style:black on white}usb{style:on green} {style:on white}pwr{style:white on green} |{style:reset} +{style:white on green}`---{style:black on white}| |{style:white on green}--------{style:black on white}| |{style:white on green}-{style:black on white}| |{style:white on green}-'{style:reset}""" + +CM_BOARD = """\ +{style:white on green}+-----------------------------------------------------------------------------------------------------------------------+{style:reset} +{style:white on green}| Raspberry Pi Compute Module |{style:reset} +{style:white on green}| |{style:reset} +{style:white on green}| You were expecting more detail? Sorry, the Compute Module's a bit hard to do right now! |{style:reset} +{style:white on green}| |{style:reset} +{style:white on green}| |{style:reset} +{style:white on green}||||||||||||||||||||-||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||{style:reset}""" + +# Pin maps for various board revisions and headers + +REV1_P1 = { +# pin func pullup pin func pullup + 1: (V3_3, False), 2: (V5, False), + 3: (GPIO0, True), 4: (V5, False), + 5: (GPIO1, True), 6: (GND, False), + 7: (GPIO4, False), 8: (GPIO14, False), + 9: (GND, False), 10: (GPIO15, False), + 11: (GPIO17, False), 12: (GPIO18, False), + 13: (GPIO21, False), 14: (GND, False), + 15: (GPIO22, False), 16: (GPIO23, False), + 17: (V3_3, False), 18: (GPIO24, False), + 19: (GPIO10, False), 20: (GND, False), + 21: (GPIO9, False), 22: (GPIO25, False), + 23: (GPIO11, False), 24: (GPIO8, False), + 25: (GND, False), 26: (GPIO7, False), + } + +REV2_P1 = { + 1: (V3_3, False), 2: (V5, False), + 3: (GPIO2, True), 4: (V5, False), + 5: (GPIO3, True), 6: (GND, False), + 7: (GPIO4, False), 8: (GPIO14, False), + 9: (GND, False), 10: (GPIO15, False), + 11: (GPIO17, False), 12: (GPIO18, False), + 13: (GPIO27, False), 14: (GND, False), + 15: (GPIO22, False), 16: (GPIO23, False), + 17: (V3_3, False), 18: (GPIO24, False), + 19: (GPIO10, False), 20: (GND, False), + 21: (GPIO9, False), 22: (GPIO25, False), + 23: (GPIO11, False), 24: (GPIO8, False), + 25: (GND, False), 26: (GPIO7, False), + } + +REV2_P5 = { + 1: (V5, False), 2: (V3_3, False), + 3: (GPIO28, False), 4: (GPIO29, False), + 5: (GPIO30, False), 6: (GPIO31, False), + 7: (GND, False), 8: (GND, False), + } + +PLUS_J8 = { + 1: (V3_3, False), 2: (V5, False), + 3: (GPIO2, True), 4: (V5, False), + 5: (GPIO3, True), 6: (GND, False), + 7: (GPIO4, False), 8: (GPIO14, False), + 9: (GND, False), 10: (GPIO15, False), + 11: (GPIO17, False), 12: (GPIO18, False), + 13: (GPIO27, False), 14: (GND, False), + 15: (GPIO22, False), 16: (GPIO23, False), + 17: (V3_3, False), 18: (GPIO24, False), + 19: (GPIO10, False), 20: (GND, False), + 21: (GPIO9, False), 22: (GPIO25, False), + 23: (GPIO11, False), 24: (GPIO8, False), + 25: (GND, False), 26: (GPIO7, False), + 27: (GPIO0, False), 28: (GPIO1, False), + 29: (GPIO5, False), 30: (GND, False), + 31: (GPIO6, False), 32: (GPIO12, False), + 33: (GPIO13, False), 34: (GND, False), + 35: (GPIO19, False), 36: (GPIO16, False), + 37: (GPIO26, False), 38: (GPIO20, False), + 39: (GND, False), 40: (GPIO21, False), + } + +CM_SODIMM = { + 1: (GND, False), 2: ('EMMC DISABLE N', False), + 3: (GPIO0, False), 4: (NC, False), + 5: (GPIO1, False), 6: (NC, False), + 7: (GND, False), 8: (NC, False), + 9: (GPIO2, False), 10: (NC, False), + 11: (GPIO3, False), 12: (NC, False), + 13: (GND, False), 14: (NC, False), + 15: (GPIO4, False), 16: (NC, False), + 17: (GPIO5, False), 18: (NC, False), + 19: (GND, False), 20: (NC, False), + 21: (GPIO6, False), 22: (NC, False), + 23: (GPIO7, False), 24: (NC, False), + 25: (GND, False), 26: (GND, False), + 27: (GPIO8, False), 28: (GPIO28, False), + 29: (GPIO9, False), 30: (GPIO29, False), + 31: (GND, False), 32: (GND, False), + 33: (GPIO10, False), 34: (GPIO30, False), + 35: (GPIO11, False), 36: (GPIO31, False), + 37: (GND, False), 38: (GND, False), + 39: ('GPIO0-27 VREF', False), 40: ('GPIO0-27 VREF', False), + # Gap in SODIMM pins + 41: ('GPIO28-45 VREF', False), 42: ('GPIO28-45 VREF', False), + 43: (GND, False), 44: (GND, False), + 45: (GPIO12, False), 46: (GPIO32, False), + 47: (GPIO13, False), 48: (GPIO33, False), + 49: (GND, False), 50: (GND, False), + 51: (GPIO14, False), 52: (GPIO34, False), + 53: (GPIO15, False), 54: (GPIO35, False), + 55: (GND, False), 56: (GND, False), + 57: (GPIO16, False), 58: (GPIO36, False), + 59: (GPIO17, False), 60: (GPIO37, False), + 61: (GND, False), 62: (GND, False), + 63: (GPIO18, False), 64: (GPIO38, False), + 65: (GPIO19, False), 66: (GPIO39, False), + 67: (GND, False), 68: (GND, False), + 69: (GPIO20, False), 70: (GPIO40, False), + 71: (GPIO21, False), 72: (GPIO41, False), + 73: (GND, False), 74: (GND, False), + 75: (GPIO22, False), 76: (GPIO42, False), + 77: (GPIO23, False), 78: (GPIO43, False), + 79: (GND, False), 80: (GND, False), + 81: (GPIO24, False), 82: (GPIO44, False), + 83: (GPIO25, False), 84: (GPIO45, False), + 85: (GND, False), 86: (GND, False), + 87: (GPIO26, False), 88: ('GPIO46 1V8', False), + 89: (GPIO27, False), 90: ('GPIO47 1V8', False), + 91: (GND, False), 92: (GND, False), + 93: ('DSI0 DN1', False), 94: ('DSI1 DP0', False), + 95: ('DSI0 DP1', False), 96: ('DSI1 DN0', False), + 97: (GND, False), 98: (GND, False), + 99: ('DSI0 DN0', False), 100: ('DSI1 CP', False), + 101: ('DSI0 DP0', False), 102: ('DSI1 CN', False), + 103: (GND, False), 104: (GND, False), + 105: ('DSI0 CN', False), 106: ('DSI1 DP3', False), + 107: ('DSI0 CP', False), 108: ('DSI1 DN3', False), + 109: (GND, False), 110: (GND, False), + 111: ('HDMI CK N', False), 112: ('DSI1 DP2', False), + 113: ('HDMI CK P', False), 114: ('DSI1 DN2', False), + 115: (GND, False), 116: (GND, False), + 117: ('HDMI D0 N', False), 118: ('DSI1 DP1', False), + 119: ('HDMI D0 P', False), 120: ('DSI1 DN1', False), + 121: (GND, False), 122: (GND, False), + 123: ('HDMI D1 N', False), 124: (NC, False), + 125: ('HDMI D1 P', False), 126: (NC, False), + 127: (GND, False), 128: (NC, False), + 129: ('HDMI D2 N', False), 130: (NC, False), + 131: ('HDMI D2 P', False), 132: (NC, False), + 133: (GND, False), 134: (GND, False), + 135: ('CAM1 DP3', False), 136: ('CAM0 DP0', False), + 137: ('CAM1 DN3', False), 138: ('CAM0 DN0', False), + 139: (GND, False), 140: (GND, False), + 141: ('CAM1 DP2', False), 142: ('CAM0 CP', False), + 143: ('CAM1 DN2', False), 144: ('CAM0 CN', False), + 145: (GND, False), 146: (GND, False), + 147: ('CAM1 CP', False), 148: ('CAM0 DP1', False), + 149: ('CAM1 CN', False), 150: ('CAM0 DN1', False), + 151: (GND, False), 152: (GND, False), + 153: ('CAM1 DP1', False), 154: (NC, False), + 155: ('CAM1 DN1', False), 156: (NC, False), + 157: (GND, False), 158: (NC, False), + 159: ('CAM1 DP0', False), 160: (NC, False), + 161: ('CAM1 DN0', False), 162: (NC, False), + 163: (GND, False), 164: (GND, False), + 165: ('USB DP', False), 166: ('TVDAC', False), + 167: ('USB DM', False), 168: ('USB OTGID', False), + 169: (GND, False), 170: (GND, False), + 171: ('HDMI CEC', False), 172: ('VC TRST N', False), + 173: ('HDMI SDA', False), 174: ('VC TDI', False), + 175: ('HDMI SCL', False), 176: ('VC TMS', False), + 177: ('RUN', False), 178: ('VC TDO', False), + 179: ('VDD CORE', False), 180: ('VC TCK', False), + 181: (GND, False), 182: (GND, False), + 183: (V1_8, False), 184: (V1_8, False), + 185: (V1_8, False), 186: (V1_8, False), + 187: (GND, False), 188: (GND, False), + 189: ('VDAC', False), 190: ('VDAC', False), + 191: (V3_3, False), 192: (V3_3, False), + 193: (V3_3, False), 194: (V3_3, False), + 195: (GND, False), 196: (GND, False), + 197: ('VBAT', False), 198: ('VBAT', False), + 199: ('VBAT', False), 200: ('VBAT', False), + } + +CM3_SODIMM = CM_SODIMM.copy() +CM3_SODIMM.update({ + 4: ('NC / SDX VREF', False), + 6: ('NC / SDX VREF', False), + 8: (GND, False), + 10: ('NC / SDX CLK', False), + 12: ('NC / SDX CMD', False), + 14: (GND, False), + 16: ('NC / SDX D0', False), + 18: ('NC / SDX D1', False), + 20: (GND, False), + 22: ('NC / SDX D2', False), + 24: ('NC / SDX D3', False), + 88: ('HDMI HPD N 1V8', False), + 90: ('EMMC EN N 1V8', False), + }) + +# The following data is sourced from a combination of the following locations: +# +# http://elinux.org/RPi_HardwareHistory +# http://elinux.org/RPi_Low-level_peripherals +# https://git.drogon.net/?p=wiringPi;a=blob;f=wiringPi/wiringPi.c#l807 + +PI_REVISIONS = { + # rev model pcb_rev released soc manufacturer ram storage usb eth wifi bt csi dsi headers board + 0x2: ('B', '1.0', '2012Q1', 'BCM2835', 'Egoman', 256, 'SD', 2, 1, False, False, 1, 1, {'P1': REV1_P1}, REV1_BOARD, ), + 0x3: ('B', '1.0', '2012Q3', 'BCM2835', 'Egoman', 256, 'SD', 2, 1, False, False, 1, 1, {'P1': REV1_P1}, REV1_BOARD, ), + 0x4: ('B', '2.0', '2012Q3', 'BCM2835', 'Sony', 256, 'SD', 2, 1, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, REV2_BOARD, ), + 0x5: ('B', '2.0', '2012Q4', 'BCM2835', 'Qisda', 256, 'SD', 2, 1, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, REV2_BOARD, ), + 0x6: ('B', '2.0', '2012Q4', 'BCM2835', 'Egoman', 256, 'SD', 2, 1, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, REV2_BOARD, ), + 0x7: ('A', '2.0', '2013Q1', 'BCM2835', 'Egoman', 256, 'SD', 1, 0, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, A_BOARD, ), + 0x8: ('A', '2.0', '2013Q1', 'BCM2835', 'Sony', 256, 'SD', 1, 0, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, A_BOARD, ), + 0x9: ('A', '2.0', '2013Q1', 'BCM2835', 'Qisda', 256, 'SD', 1, 0, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, A_BOARD, ), + 0xd: ('B', '2.0', '2012Q4', 'BCM2835', 'Egoman', 512, 'SD', 2, 1, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, REV2_BOARD, ), + 0xe: ('B', '2.0', '2012Q4', 'BCM2835', 'Sony', 512, 'SD', 2, 1, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, REV2_BOARD, ), + 0xf: ('B', '2.0', '2012Q4', 'BCM2835', 'Qisda', 512, 'SD', 2, 1, False, False, 1, 1, {'P1': REV2_P1, 'P5': REV2_P5}, REV2_BOARD, ), + 0x10: ('B+', '1.2', '2014Q3', 'BCM2835', 'Sony', 512, 'MicroSD', 4, 1, False, False, 1, 1, {'J8': PLUS_J8}, BPLUS_BOARD, ), + 0x11: ('CM', '1.1', '2014Q2', 'BCM2835', 'Sony', 512, 'eMMC', 1, 0, False, False, 2, 2, {'SODIMM': CM_SODIMM}, CM_BOARD, ), + 0x12: ('A+', '1.1', '2014Q4', 'BCM2835', 'Sony', 256, 'MicroSD', 1, 0, False, False, 1, 1, {'J8': PLUS_J8}, APLUS_BOARD, ), + 0x13: ('B+', '1.2', '2015Q1', 'BCM2835', 'Egoman', 512, 'MicroSD', 4, 1, False, False, 1, 1, {'J8': PLUS_J8}, BPLUS_BOARD, ), + 0x14: ('CM', '1.1', '2014Q2', 'BCM2835', 'Embest', 512, 'eMMC', 1, 0, False, False, 2, 2, {'SODIMM': CM_SODIMM}, CM_BOARD, ), + 0x15: ('A+', '1.1', '2014Q4', 'BCM2835', 'Embest', 256, 'MicroSD', 1, 0, False, False, 1, 1, {'J8': PLUS_J8}, APLUS_BOARD, ), + } + + +# ANSI color codes, for the pretty printers (nothing comprehensive, just enough +# for our purposes) + +class Style(object): + def __init__(self, color=None): + self.color = self._term_supports_color() if color is None else bool(color) + self.effects = { + 'reset': 0, + 'bold': 1, + 'normal': 22, + } + self.colors = { + 'black': 0, + 'red': 1, + 'green': 2, + 'yellow': 3, + 'blue': 4, + 'magenta': 5, + 'cyan': 6, + 'white': 7, + 'default': 9, + } + + @staticmethod + def _term_supports_color(): + try: + stdout_fd = sys.stdout.fileno() + except IOError: + return False + else: + is_a_tty = os.isatty(stdout_fd) + is_windows = sys.platform.startswith('win') + return is_a_tty and not is_windows + + @classmethod + def from_style_content(cls, format_spec): + specs = set(format_spec.split()) + style = specs & {'mono', 'color'} + content = specs - style + if len(style) > 1: + raise ValueError('cannot specify both mono and color styles') + try: + style = style.pop() + except KeyError: + style = 'color' if cls._term_supports_color() else 'mono' + if len(content) > 1: + raise ValueError('cannot specify more than one content element') + try: + content = content.pop() + except KeyError: + content = 'full' + return cls(style == 'color'), content + + def __call__(self, format_spec): + specs = format_spec.split() + codes = [] + fore = True + for spec in specs: + if spec == 'on': + fore = False + else: + try: + codes.append(self.effects[spec]) + except KeyError: + try: + if fore: + codes.append(30 + self.colors[spec]) + else: + codes.append(40 + self.colors[spec]) + except KeyError: + raise ValueError('invalid format specification "%s"' % spec) + if self.color: + return '\x1b[%sm' % (';'.join(str(code) for code in codes)) + else: + return '' + + def __format__(self, format_spec): + if format_spec == '': + return 'color' if self.color else 'mono' + else: + return self(format_spec) + + +class PinInfo(namedtuple('PinInfo', ( + 'number', + 'function', + 'pull_up', + 'row', + 'col', + ))): + """ + This class is a :func:`~collections.namedtuple` derivative used to + represent information about a pin present on a GPIO header. The following + attributes are defined: + + .. attribute:: number + + An integer containing the physical pin number on the header (starting + from 1 in accordance with convention). + + .. attribute:: function + + A string describing the function of the pin. Some common examples + include "GND" (for pins connecting to ground), "3V3" (for pins which + output 3.3 volts), "GPIO9" (for GPIO9 in the Broadcom numbering + scheme), etc. + + .. attribute:: pull_up + + A bool indicating whether the pin has a physical pull-up resistor + permanently attached (this is usually ``False`` but GPIO2 and GPIO3 + are *usually* ``True``). This is used internally by gpiozero to raise + errors when pull-down is requested on a pin with a physical pull-up + resistor. + + .. attribute:: row + + An integer indicating on which row the pin is physically located in + the header (1-based) + + .. attribute:: col + + An integer indicating in which column the pin is physically located + in the header (1-based) + """ + __slots__ = () # workaround python issue #24931 + + +class HeaderInfo(namedtuple('HeaderInfo', ( + 'name', + 'rows', + 'columns', + 'pins', + ))): + """ + This class is a :func:`~collections.namedtuple` derivative used to + represent information about a pin header on a board. The object can be used + in a format string with various custom specifications:: + + from gpiozero import * + + print('{0}'.format(pi_info().headers['J8'])) + print('{0:full}'.format(pi_info().headers['J8'])) + print('{0:col2}'.format(pi_info().headers['P1'])) + print('{0:row1}'.format(pi_info().headers['P1'])) + + `'color'` and `'mono'` can be prefixed to format specifications to force + the use of `ANSI color codes`_. If neither is specified, ANSI codes will + only be used if stdout is detected to be a tty:: + + print('{0:color row2}'.format(pi_info().headers['J8'])) # force use of ANSI codes + print('{0:mono row2}'.format(pi_info().headers['P1'])) # force plain ASCII + + The following attributes are defined: + + .. automethod:: pprint + + .. attribute:: name + + The name of the header, typically as it appears silk-screened on the + board (e.g. "P1" or "J8"). + + .. attribute:: rows + + The number of rows on the header. + + .. attribute:: columns + + The number of columns on the header. + + .. attribute:: pins + + A dictionary mapping physical pin numbers to :class:`PinInfo` tuples. + + .. _ANSI color codes: https://en.wikipedia.org/wiki/ANSI_escape_code + """ + __slots__ = () # workaround python issue #24931 + + def _func_style(self, function, style): + if function == V5: + return style('bold red') + elif function in (V3_3, V1_8): + return style('bold cyan') + elif function in (GND, NC): + return style('bold black') + elif function.startswith('GPIO') and function[4:].isdigit(): + return style('bold green') + else: + return style('yellow') + + def _format_full(self, style): + Cell = namedtuple('Cell', ('content', 'align', 'style')) + + lines = [] + for row in range(self.rows): + line = [] + for col in range(self.columns): + pin = (row * self.columns) + col + 1 + try: + pin = self.pins[pin] + cells = [ + Cell(pin.function, '><'[col % 2], self._func_style(pin.function, style)), + Cell('(%d)' % pin.number, '><'[col % 2], ''), + ] + if col % 2: + cells = reversed(cells) + line.extend(cells) + except KeyError: + line.append(Cell('', '<', '')) + lines.append(line) + cols = list(zip(*lines)) + col_lens = [max(len(cell.content) for cell in col) for col in cols] + lines = [ + ' '.join( + '{cell.style}{cell.content:{cell.align}{width}s}{style:reset}'.format( + cell=cell, width=width, style=style) + for cell, width, align in zip(line, col_lens, cycle('><'))) + for line in lines + ] + return '\n'.join(lines) + + def _format_pin(self, pin, style): + return ''.join(( + style('on black'), + ( + ' ' if pin is None else + self._func_style(pin.function, style) + + ('1' if pin.number == 1 else 'o') + ), + style('reset') + )) + + def _format_row(self, row, style): + if row > self.rows: + raise ValueError('invalid row %d for header %s' % (row, self.name)) + start_pin = (row - 1) * self.columns + 1 + return ''.join( + self._format_pin(pin, style) + for n in range(start_pin, start_pin + self.columns) + for pin in (self.pins.get(n),) + ) + + def _format_col(self, col, style): + if col > self.columns: + raise ValueError('invalid col %d for header %s' % (col, self.name)) + return ''.join( + self._format_pin(pin, style) + for n in range(col, self.rows * self.columns + 1, self.columns) + for pin in (self.pins.get(n),) + ) + + def __format__(self, format_spec): + style, content = Style.from_style_content(format_spec) + if content == 'full': + return self._format_full(style) + elif content.startswith('row') and content[3:].isdigit(): + return self._format_row(int(content[3:]), style) + elif content.startswith('col') and content[3:].isdigit(): + return self._format_col(int(content[3:]), style) + + def pprint(self, color=None): + """ + Pretty-print a diagram of the header pins. + + If *color* is ``None`` (the default, the diagram will include ANSI + color codes if stdout is a color-capable terminal). Otherwise *color* + can be set to ``True`` or ``False`` to force color or monochrome + output. + """ + print('{0:{style} full}'.format(self, style=Style(color))) + + +class PiBoardInfo(namedtuple('PiBoardInfo', ( + 'revision', + 'model', + 'pcb_revision', + 'released', + 'soc', + 'manufacturer', + 'memory', + 'storage', + 'usb', + 'ethernet', + 'wifi', + 'bluetooth', + 'csi', + 'dsi', + 'headers', + 'board', + ))): + """ + This class is a :func:`~collections.namedtuple` derivative used to + represent information about a particular model of Raspberry Pi. While it is + a tuple, it is strongly recommended that you use the following named + attributes to access the data contained within. The object can be used + in format strings with various custom format specifications:: + + from gpiozero import * + + print('{0}'.format(pi_info())) + print('{0:full}'.format(pi_info())) + print('{0:board}'.format(pi_info())) + print('{0:specs}'.format(pi_info())) + print('{0:headers}'.format(pi_info())) + + `'color'` and `'mono'` can be prefixed to format specifications to force + the use of `ANSI color codes`_. If neither is specified, ANSI codes will + only be used if stdout is detected to be a tty:: + + print('{0:color board}'.format(pi_info())) # force use of ANSI codes + print('{0:mono board}'.format(pi_info())) # force plain ASCII + + .. _ANSI color codes: https://en.wikipedia.org/wiki/ANSI_escape_code + + .. automethod:: physical_pin + + .. automethod:: physical_pins + + .. automethod:: pprint + + .. automethod:: pulled_up + + .. attribute:: revision + + A string indicating the revision of the Pi. This is unique to each + revision and can be considered the "key" from which all other + attributes are derived. However, in itself the string is fairly + meaningless. + + .. attribute:: model + + A string containing the model of the Pi (for example, "B", "B+", "A+", + "2B", "CM" (for the Compute Module), or "Zero"). + + .. attribute:: pcb_revision + + A string containing the PCB revision number which is silk-screened onto + the Pi (on some models). + + .. note:: + + This is primarily useful to distinguish between the model B + revision 1.0 and 2.0 (not to be confused with the model 2B) which + had slightly different pinouts on their 26-pin GPIO headers. + + .. attribute:: released + + A string containing an approximate release date for this revision of + the Pi (formatted as yyyyQq, e.g. 2012Q1 means the first quarter of + 2012). + + .. attribute:: soc + + A string indicating the SoC (`system on a chip`_) that this revision + of the Pi is based upon. + + .. attribute:: manufacturer + + A string indicating the name of the manufacturer (usually "Sony" but a + few others exist). + + .. attribute:: memory + + An integer indicating the amount of memory (in Mb) connected to the + SoC. + + .. note:: + + This can differ substantially from the amount of RAM available + to the operating system as the GPU's memory is shared with the + CPU. When the camera module is activated, at least 128Mb of RAM + is typically reserved for the GPU. + + .. attribute:: storage + + A string indicating the type of bootable storage used with this + revision of Pi, e.g. "SD", "MicroSD", or "eMMC" (for the Compute + Module). + + .. attribute:: usb + + An integer indicating how many USB ports are physically present on + this revision of the Pi. + + .. note:: + + This does *not* include the micro-USB port used to power the Pi. + + .. attribute:: ethernet + + An integer indicating how many Ethernet ports are physically present + on this revision of the Pi. + + .. attribute:: wifi + + A bool indicating whether this revision of the Pi has wifi built-in. + + .. attribute:: bluetooth + + A bool indicating whether this revision of the Pi has bluetooth + built-in. + + .. attribute:: csi + + An integer indicating the number of CSI (camera) ports available on + this revision of the Pi. + + .. attribute:: dsi + + An integer indicating the number of DSI (display) ports available on + this revision of the Pi. + + .. attribute:: headers + + A dictionary which maps header labels to :class:`HeaderInfo` tuples. + For example, to obtain information about header P1 you would query + ``headers['P1']``. To obtain information about pin 12 on header J8 you + would query ``headers['J8'].pins[12]``. + + A rendered version of this data can be obtained by using the + :class:`PiBoardInfo` object in a format string:: + + from gpiozero import * + print('{0:headers}'.format(pi_info())) + + .. attribute:: board + + An ASCII art rendition of the board, primarily intended for console + pretty-print usage. A more usefully rendered version of this data can + be obtained by using the :class:`PiBoardInfo` object in a format + string. For example:: + + from gpiozero import * + print('{0:board}'.format(pi_info())) + + .. _system on a chip: https://en.wikipedia.org/wiki/System_on_a_chip + """ + __slots__ = () # workaround python issue #24931 + + @classmethod + def from_revision(cls, revision): + if revision & 0x800000: + # New-style revision, parse information from bit-pattern: + # + # MSB -----------------------> LSB + # uuuuuuuuFMMMCCCCPPPPTTTTTTTTRRRR + # + # uuuuuuuu - Unused + # F - New flag (1=valid new-style revision, 0=old-style) + # MMM - Memory size (0=256, 1=512, 2=1024) + # CCCC - Manufacturer (0=Sony, 1=Egoman, 2=Embest, 3=Sony Japan) + # PPPP - Processor (0=2835, 1=2836, 2=2837) + # TTTTTTTT - Type (0=A, 1=B, 2=A+, 3=B+, 4=2B, 5=Alpha (??), 6=CM, + # 8=3B, 9=Zero, 10=CM3, 12=Zero W) + # RRRR - Revision (0, 1, 2, etc.) + revcode_memory = (revision & 0x700000) >> 20 + revcode_manufacturer = (revision & 0xf0000) >> 16 + revcode_processor = (revision & 0xf000) >> 12 + revcode_type = (revision & 0xff0) >> 4 + revcode_revision = (revision & 0x0f) + try: + model = { + 0: 'A', + 1: 'B', + 2: 'A+', + 3: 'B+', + 4: '2B', + 6: 'CM', + 8: '3B', + 9: 'Zero', + 10: 'CM3', + 12: 'Zero W', + }.get(revcode_type, '???') + if model in ('A', 'B'): + pcb_revision = { + 0: '1.0', # is this right? + 1: '1.0', + 2: '2.0', + }.get(revcode_revision, 'Unknown') + else: + pcb_revision = '1.%d' % revcode_revision + soc = { + 0: 'BCM2835', + 1: 'BCM2836', + 2: 'BCM2837', + }.get(revcode_processor, 'Unknown') + manufacturer = { + 0: 'Sony', + 1: 'Egoman', + 2: 'Embest', + 3: 'Sony Japan', + }.get(revcode_manufacturer, 'Unknown') + memory = { + 0: 256, + 1: 512, + 2: 1024, + }.get(revcode_memory, None) + released = { + 'A': '2013Q1', + 'B': '2012Q1' if pcb_revision == '1.0' else '2012Q4', + 'A+': '2014Q4' if memory == 512 else '2016Q3', + 'B+': '2014Q3', + '2B': '2015Q1' if pcb_revision in ('1.0', '1.1') else '2016Q3', + 'CM': '2014Q2', + '3B': '2016Q1' if manufacturer in ('Sony', 'Embest') else '2016Q4', + 'Zero': '2015Q4' if pcb_revision == '1.2' else '2016Q2', + 'CM3': '2017Q1', + 'Zero W': '2017Q1', + }.get(model, 'Unknown') + storage = { + 'A': 'SD', + 'B': 'SD', + 'CM': 'eMMC', + 'CM3': 'eMMC / off-board', + }.get(model, 'MicroSD') + usb = { + 'A': 1, + 'A+': 1, + 'Zero': 1, + 'Zero W': 1, + 'B': 2, + 'CM': 0, + 'CM3': 1, + }.get(model, 4) + ethernet = { + 'A': 0, + 'A+': 0, + 'Zero': 0, + 'Zero W': 0, + 'CM': 0, + 'CM3': 0, + }.get(model, 1) + wifi = { + '3B': True, + 'Zero W': True, + }.get(model, False) + bluetooth = { + '3B': True, + 'Zero W': True, + }.get(model, False) + csi = { + 'Zero': 0 if pcb_revision == '1.0' else 1, + 'Zero W': 1, + 'CM': 2, + 'CM3': 2, + }.get(model, 1) + dsi = { + 'Zero': 0, + 'Zero W': 0, + }.get(model, csi) + headers = { + 'A': {'P1': REV2_P1, 'P5': REV2_P5}, + 'B': {'P1': REV1_P1} if pcb_revision == '1.0' else {'P1': REV2_P1, 'P5': REV2_P5}, + 'CM': {'SODIMM': CM_SODIMM}, + 'CM3': {'SODIMM': CM3_SODIMM}, + }.get(model, {'J8': PLUS_J8}) + board = { + 'A': A_BOARD, + 'B': REV1_BOARD if pcb_revision == '1.0' else REV2_BOARD, + 'A+': APLUS_BOARD, + 'CM': CM_BOARD, + 'CM3': CM_BOARD, + 'Zero': ZERO12_BOARD if pcb_revision == '1.2' else ZERO13_BOARD, + 'Zero W': ZERO13_BOARD, + }.get(model, BPLUS_BOARD) + except KeyError: + raise PinUnknownPi('unable to parse new-style revision "%x"' % revision) + else: + # Old-style revision, use the lookup table + try: + ( + model, + pcb_revision, + released, + soc, + manufacturer, + memory, + storage, + usb, + ethernet, + wifi, + bluetooth, + csi, + dsi, + headers, + board, + ) = PI_REVISIONS[revision] + except KeyError: + raise PinUnknownPi('unknown old-style revision "%x"' % revision) + headers = { + header: HeaderInfo(name=header, rows=max(header_data) // 2, columns=2, pins={ + number: PinInfo( + number=number, function=function, pull_up=pull_up, + row=row + 1, col=col + 1) + for number, (function, pull_up) in header_data.items() + for row, col in (divmod(number, 2),) + }) + for header, header_data in headers.items() + } + return cls( + '%04x' % revision, + model, + pcb_revision, + released, + soc, + manufacturer, + memory, + storage, + usb, + ethernet, + wifi, + bluetooth, + csi, + dsi, + headers, + board, + ) + + def physical_pins(self, function): + """ + Return the physical pins supporting the specified *function* as tuples + of ``(header, pin_number)`` where *header* is a string specifying the + header containing the *pin_number*. Note that the return value is a + :class:`set` which is not indexable. Use :func:`physical_pin` if you + are expecting a single return value. + + :param str function: + The pin function you wish to search for. Usually this is something + like "GPIO9" for Broadcom GPIO pin 9, or "GND" for all the pins + connecting to electrical ground. + """ + return { + (header, pin.number) + for (header, info) in self.headers.items() + for pin in info.pins.values() + if pin.function == function + } + + def physical_pin(self, function): + """ + Return the physical pin supporting the specified *function*. If no pins + support the desired *function*, this function raises :exc:`PinNoPins`. + If multiple pins support the desired *function*, :exc:`PinMultiplePins` + will be raised (use :func:`physical_pins` if you expect multiple pins + in the result, such as for electrical ground). + + :param str function: + The pin function you wish to search for. Usually this is something + like "GPIO9" for Broadcom GPIO pin 9. + """ + result = self.physical_pins(function) + if len(result) > 1: + raise PinMultiplePins('multiple pins can be used for %s' % function) + elif result: + return result.pop() + else: + raise PinNoPins('no pins can be used for %s' % function) + + def pulled_up(self, function): + """ + Returns a bool indicating whether a physical pull-up is attached to + the pin supporting the specified *function*. Either :exc:`PinNoPins` + or :exc:`PinMultiplePins` may be raised if the function is not + associated with a single pin. + + :param str function: + The pin function you wish to determine pull-up for. Usually this is + something like "GPIO9" for Broadcom GPIO pin 9. + """ + try: + header, number = self.physical_pin(function) + except PinNoPins: + return False + else: + return self.headers[header].pins[number].pull_up + + def __repr__(self): + return '{cls}({fields})'.format( + cls=self.__class__.__name__, + fields=', '.join( + ( + '{name}=...' if name in ('headers', 'board') else + '{name}={value!r}').format(name=name, value=value) + for name, value in zip(self._fields, self) + ) + ) + + def __format__(self, format_spec): + style, content = Style.from_style_content(format_spec) + if content == 'full': + return dedent("""\ + {self:{style} board} + + {self:{style} specs} + + {self:{style} headers}""" + ).format(self=self, style=style) + elif content == 'board': + kw = self._asdict() + kw.update({ + name: header + for name, header in self.headers.items() + }) + return self.board.format(style=style, **kw) + elif content == 'specs': + return dedent("""\ + {style:bold}Revision {style:reset}: {revision} + {style:bold}SoC {style:reset}: {soc} + {style:bold}RAM {style:reset}: {memory}Mb + {style:bold}Storage {style:reset}: {storage} + {style:bold}USB ports {style:reset}: {usb} {style:yellow}(excluding power){style:reset} + {style:bold}Ethernet ports {style:reset}: {ethernet} + {style:bold}Wi-fi {style:reset}: {wifi} + {style:bold}Bluetooth {style:reset}: {bluetooth} + {style:bold}Camera ports (CSI) {style:reset}: {csi} + {style:bold}Display ports (DSI){style:reset}: {dsi}""" + ).format(style=style, **self._asdict()) + elif content == 'headers': + return '\n\n'.join( + dedent("""\ + {style:bold}{header.name}{style:reset}: + {header:{style} full}""" + ).format(header=header, style=style) + for header in sorted(self.headers.values(), key=attrgetter('name')) + ) + + def pprint(self, color=None): + """ + Pretty-print a representation of the board along with header diagrams. + + If *color* is ``None`` (the default), the diagram will include ANSI + color codes if stdout is a color-capable terminal. Otherwise *color* + can be set to ``True`` or ``False`` to force color or monochrome + output. + """ + print('{0:{style} full}'.format(self, style=Style(color))) + + +def pi_info(revision=None): + """ + Returns a :class:`PiBoardInfo` instance containing information about a + *revision* of the Raspberry Pi. + + :param str revision: + The revision of the Pi to return information about. If this is omitted + or ``None`` (the default), then the library will attempt to determine + the model of Pi it is running on and return information about that. + """ + if revision is None: + # The reason this import is located here is to avoid a circular + # dependency; devices->pins.local->pins.data->devices + from ..devices import Device + result = Device.pin_factory.pi_info + if result is None: + raise PinUnknownPi('The default pin_factory is not attached to a Pi') + else: + return result + else: + if isinstance(revision, bytes): + revision = revision.decode('ascii') + if isinstance(revision, str): + revision = int(revision, base=16) + else: + # be nice to people passing an int (or something numeric anyway) + revision = int(revision) + return PiBoardInfo.from_revision(revision) + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/local.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/local.py new file mode 100644 index 00000000..c0e0a851 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/local.py @@ -0,0 +1,244 @@ +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + +import io + +import warnings +from collections import defaultdict +from threading import Lock + +try: + from spidev import SpiDev +except ImportError: + SpiDev = None + +from . import SPI +from .pi import PiFactory, PiPin, SPI_HARDWARE_PINS +from .spi import SPISoftwareBus +from ..devices import Device, SharedMixin +from ..output_devices import OutputDevice +from ..exc import DeviceClosed, PinUnknownPi, SPIInvalidClockMode + + +class LocalPiFactory(PiFactory): + """ + Abstract base class representing pins attached locally to a Pi. This forms + the base class for local-only pin interfaces + (:class:`~gpiozero.pins.rpigpio.RPiGPIOPin`, + :class:`~gpiozero.pins.rpio.RPIOPin`, and + :class:`~gpiozero.pins.native.NativePin`). + """ + pins = {} + _reservations = defaultdict(list) + _res_lock = Lock() + + def __init__(self): + super(LocalPiFactory, self).__init__() + self.spi_classes = { + ('hardware', 'exclusive'): LocalPiHardwareSPI, + ('hardware', 'shared'): LocalPiHardwareSPIShared, + ('software', 'exclusive'): LocalPiSoftwareSPI, + ('software', 'shared'): LocalPiSoftwareSPIShared, + } + # Override the reservations and pins dict to be this class' attributes. + # This is a bit of a dirty hack, but ensures that anyone evil enough to + # mix pin implementations doesn't try and control the same pin with + # different backends + self.pins = LocalPiFactory.pins + self._reservations = LocalPiFactory._reservations + self._res_lock = LocalPiFactory._res_lock + + def _get_revision(self): + # Cache the result as we can reasonably assume it won't change during + # runtime (this is LocalPin after all; descendents that deal with + # remote Pis should inherit from Pin instead) + + with open('/proc/cpuinfo', 'r') as f: + for line in f: + if line.startswith('Revision'): + revision = line.split(':')[1].strip().lower() + overvolted = revision.startswith('100') + if overvolted: + revision = revision[-4:] + return revision + raise PinUnknownPi('unable to locate Pi revision in /proc/cpuinfo') + + +class LocalPiPin(PiPin): + """ + Abstract base class representing a multi-function GPIO pin attached to the + local Raspberry Pi. + """ + pass + + +class LocalPiHardwareSPI(SPI, Device): + def __init__(self, factory, port, device): + self._port = port + self._device = device + self._interface = None + if SpiDev is None: + raise ImportError('failed to import spidev') + super(LocalPiHardwareSPI, self).__init__() + pins = SPI_HARDWARE_PINS[port] + self.pin_factory.reserve_pins( + self, + pins['clock'], + pins['mosi'], + pins['miso'], + pins['select'][device] + ) + self._interface = SpiDev() + self._interface.open(port, device) + self._interface.max_speed_hz = 500000 + + def close(self): + if getattr(self, '_interface', None): + self._interface.close() + self._interface = None + self.pin_factory.release_all(self) + super(LocalPiHardwareSPI, self).close() + + @property + def closed(self): + return self._interface is None + + def __repr__(self): + try: + self._check_open() + return 'SPI(port=%d, device=%d)' % (self._port, self._device) + except DeviceClosed: + return 'SPI(closed)' + + def transfer(self, data): + """ + Writes data (a list of integer words where each word is assumed to have + :attr:`bits_per_word` bits or less) to the SPI interface, and reads an + equivalent number of words, returning them as a list of integers. + """ + return self._interface.xfer2(data) + + def _get_clock_mode(self): + return self._interface.mode + + def _set_clock_mode(self, value): + self._interface.mode = value + + def _get_lsb_first(self): + return self._interface.lsbfirst + + def _set_lsb_first(self, value): + self._interface.lsbfirst = bool(value) + + def _get_select_high(self): + return self._interface.cshigh + + def _set_select_high(self, value): + self._interface.cshigh = bool(value) + + def _get_bits_per_word(self): + return self._interface.bits_per_word + + def _set_bits_per_word(self, value): + self._interface.bits_per_word = value + + +class LocalPiSoftwareSPI(SPI, OutputDevice): + def __init__(self, factory, clock_pin, mosi_pin, miso_pin, select_pin): + self._bus = None + super(LocalPiSoftwareSPI, self).__init__(select_pin, active_high=False) + try: + self._clock_phase = False + self._lsb_first = False + self._bits_per_word = 8 + self._bus = SPISoftwareBus(clock_pin, mosi_pin, miso_pin) + except: + self.close() + raise + + def _conflicts_with(self, other): + # XXX Need to refine this + return not ( + isinstance(other, LocalPiSoftwareSPI) and + (self.pin.number != other.pin.number) + ) + + def close(self): + if getattr(self, '_bus', None): + self._bus.close() + self._bus = None + super(LocalPiSoftwareSPI, self).close() + + @property + def closed(self): + return self._bus is None + + def __repr__(self): + try: + self._check_open() + return 'SPI(clock_pin=%d, mosi_pin=%d, miso_pin=%d, select_pin=%d)' % ( + self._bus.clock.pin.number, + self._bus.mosi.pin.number, + self._bus.miso.pin.number, + self.pin.number) + except DeviceClosed: + return 'SPI(closed)' + + def transfer(self, data): + with self._bus.lock: + self.on() + try: + return self._bus.transfer( + data, self._clock_phase, self._lsb_first, self._bits_per_word) + finally: + self.off() + + def _get_clock_mode(self): + with self._bus.lock: + return (not self._bus.clock.active_high) << 1 | self._clock_phase + + def _set_clock_mode(self, value): + if not (0 <= value < 4): + raise SPIInvalidClockMode("%d is not a valid clock mode" % value) + with self._bus.lock: + self._bus.clock.active_high = not (value & 2) + self._clock_phase = bool(value & 1) + + def _get_lsb_first(self): + return self._lsb_first + + def _set_lsb_first(self, value): + self._lsb_first = bool(value) + + def _get_bits_per_word(self): + return self._bits_per_word + + def _set_bits_per_word(self, value): + if value < 1: + raise ValueError('bits_per_word must be positive') + self._bits_per_word = int(value) + + def _get_select_high(self): + return self.active_high + + def _set_select_high(self, value): + with self._bus.lock: + self.active_high = value + self.off() + + +class LocalPiHardwareSPIShared(SharedMixin, LocalPiHardwareSPI): + @classmethod + def _shared_key(cls, factory, port, device): + return (port, device) + + +class LocalPiSoftwareSPIShared(SharedMixin, LocalPiSoftwareSPI): + @classmethod + def _shared_key(cls, factory, clock_pin, mosi_pin, miso_pin, select_pin): + return (select_pin,) diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/mock.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/mock.py new file mode 100644 index 00000000..ca856ecb --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/mock.py @@ -0,0 +1,469 @@ +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + + +import os +from collections import namedtuple +from time import time, sleep +from threading import Thread, Event +try: + from math import isclose +except ImportError: + from ..compat import isclose + +#import pkg_resources + +from ..exc import ( + PinPWMUnsupported, + PinSetInput, + PinFixedPull, + PinInvalidFunction, + PinInvalidPull, + ) +from ..devices import Device +from .pi import PiPin +from .local import LocalPiFactory + + +PinState = namedtuple('PinState', ('timestamp', 'state')) + +class MockPin(PiPin): + """ + A mock pin used primarily for testing. This class does *not* support PWM. + """ + + def __init__(self, factory, number): + super(MockPin, self).__init__(factory, number) + self._function = 'input' + self._pull = 'up' if self.factory.pi_info.pulled_up(repr(self)) else 'floating' + self._state = self._pull == 'up' + self._bounce = None + self._edges = 'both' + self._when_changed = None + self.clear_states() + + def close(self): + self.when_changed = None + self.function = 'input' + + def _get_function(self): + return self._function + + def _set_function(self, value): + if value not in ('input', 'output'): + raise PinInvalidFunction('function must be input or output') + self._function = value + if value == 'input': + # Drive the input to the pull + self._set_pull(self._get_pull()) + + def _get_state(self): + return self._state + + def _set_state(self, value): + if self._function == 'input': + raise PinSetInput('cannot set state of pin %r' % self) + assert self._function == 'output' + assert 0 <= value <= 1 + self._change_state(bool(value)) + + def _change_state(self, value): + if self._state != value: + t = time() + self._state = value + self.states.append(PinState(t - self._last_change, value)) + self._last_change = t + return True + return False + + def _get_frequency(self): + return None + + def _set_frequency(self, value): + if value is not None: + raise PinPWMUnsupported() + + def _get_pull(self): + return self._pull + + def _set_pull(self, value): + if self.function != 'input': + raise PinFixedPull('cannot set pull on non-input pin %r' % self) + if value != 'up' and self.factory.pi_info.pulled_up(repr(self)): + raise PinFixedPull('%r has a physical pull-up resistor' % self) + if value not in ('floating', 'up', 'down'): + raise PinInvalidPull('pull must be floating, up, or down') + self._pull = value + if value == 'up': + self.drive_high() + elif value == 'down': + self.drive_low() + + def _get_bounce(self): + return self._bounce + + def _set_bounce(self, value): + # XXX Need to implement this + self._bounce = value + + def _get_edges(self): + return self._edges + + def _set_edges(self, value): + assert value in ('none', 'falling', 'rising', 'both') + self._edges = value + + def _get_when_changed(self): + return self._when_changed + + def _set_when_changed(self, value): + self._when_changed = value + + def drive_high(self): + assert self._function == 'input' + if self._change_state(True): + if self._edges in ('both', 'rising') and self._when_changed is not None: + self._when_changed() + + def drive_low(self): + assert self._function == 'input' + if self._change_state(False): + if self._edges in ('both', 'falling') and self._when_changed is not None: + self._when_changed() + + def clear_states(self): + self._last_change = time() + self.states = [PinState(0.0, self._state)] + + def assert_states(self, expected_states): + # Tests that the pin went through the expected states (a list of values) + for actual, expected in zip(self.states, expected_states): + assert actual.state == expected + + def assert_states_and_times(self, expected_states): + # Tests that the pin went through the expected states at the expected + # times (times are compared with a tolerance of tens-of-milliseconds as + # that's about all we can reasonably expect in a non-realtime + # environment on a Pi 1) + for actual, expected in zip(self.states, expected_states): + assert isclose(actual.timestamp, expected[0], rel_tol=0.05, abs_tol=0.05) + assert isclose(actual.state, expected[1]) + + +class MockConnectedPin(MockPin): + """ + This derivative of :class:`MockPin` emulates a pin connected to another + mock pin. This is used in the "real pins" portion of the test suite to + check that one pin can influence another. + """ + def __init__(self, factory, number, input_pin=None): + super(MockConnectedPin, self).__init__(factory, number) + self.input_pin = input_pin + + def _change_state(self, value): + if self.input_pin: + if value: + self.input_pin.drive_high() + else: + self.input_pin.drive_low() + return super(MockConnectedPin, self)._change_state(value) + + +class MockChargingPin(MockPin): + """ + This derivative of :class:`MockPin` emulates a pin which, when set to + input, waits a predetermined length of time and then drives itself high + (as if attached to, e.g. a typical circuit using an LDR and a capacitor + to time the charging rate). + """ + def __init__(self, factory, number, charge_time=0.01): + super(MockChargingPin, self).__init__(factory, number) + self.charge_time = charge_time # dark charging time + self._charge_stop = Event() + self._charge_thread = None + + def _set_function(self, value): + super(MockChargingPin, self)._set_function(value) + if value == 'input': + if self._charge_thread: + self._charge_stop.set() + self._charge_thread.join() + self._charge_stop.clear() + self._charge_thread = Thread(target=self._charge) + self._charge_thread.start() + elif value == 'output': + if self._charge_thread: + self._charge_stop.set() + self._charge_thread.join() + + def _charge(self): + if not self._charge_stop.wait(self.charge_time): + try: + self.drive_high() + except AssertionError: + # Charging pins are typically flipped between input and output + # repeatedly; if another thread has already flipped us to + # output ignore the assertion-error resulting from attempting + # to drive the pin high + pass + + +class MockTriggerPin(MockPin): + """ + This derivative of :class:`MockPin` is intended to be used with another + :class:`MockPin` to emulate a distance sensor. Set *echo_pin* to the + corresponding pin instance. When this pin is driven high it will trigger + the echo pin to drive high for the echo time. + """ + def __init__(self, factory, number, echo_pin=None, echo_time=0.04): + super(MockTriggerPin, self).__init__(factory, number) + self.echo_pin = echo_pin + self.echo_time = echo_time # longest echo time + self._echo_thread = None + + def _set_state(self, value): + super(MockTriggerPin, self)._set_state(value) + if value: + if self._echo_thread: + self._echo_thread.join() + self._echo_thread = Thread(target=self._echo) + self._echo_thread.start() + + def _echo(self): + sleep(0.001) + self.echo_pin.drive_high() + sleep(self.echo_time) + self.echo_pin.drive_low() + + +class MockPWMPin(MockPin): + """ + This derivative of :class:`MockPin` adds PWM support. + """ + def __init__(self, factory, number): + super(MockPWMPin, self).__init__(factory, number) + self._frequency = None + + def close(self): + self.frequency = None + super(MockPWMPin, self).close() + + def _set_state(self, value): + if self._function == 'input': + raise PinSetInput('cannot set state of pin %r' % self) + assert self._function == 'output' + assert 0 <= value <= 1 + self._change_state(float(value)) + + def _get_frequency(self): + return self._frequency + + def _set_frequency(self, value): + if value is not None: + assert self._function == 'output' + self._frequency = value + if value is None: + self._change_state(0.0) + + +class MockSPIClockPin(MockPin): + """ + This derivative of :class:`MockPin` is intended to be used as the clock pin + of a mock SPI device. It is not intended for direct construction in tests; + rather, construct a :class:`MockSPIDevice` with various pin numbers, and + this class will be used for the clock pin. + """ + def __init__(self, factory, number): + super(MockSPIClockPin, self).__init__(factory, number) + if not hasattr(self, 'spi_devices'): + self.spi_devices = [] + + def _set_state(self, value): + super(MockSPIClockPin, self)._set_state(value) + for dev in self.spi_devices: + dev.on_clock() + + +class MockSPISelectPin(MockPin): + """ + This derivative of :class:`MockPin` is intended to be used as the select + pin of a mock SPI device. It is not intended for direct construction in + tests; rather, construct a :class:`MockSPIDevice` with various pin numbers, + and this class will be used for the select pin. + """ + def __init__(self, factory, number): + super(MockSPISelectPin, self).__init__(factory, number) + if not hasattr(self, 'spi_device'): + self.spi_device = None + + def _set_state(self, value): + super(MockSPISelectPin, self)._set_state(value) + if self.spi_device: + self.spi_device.on_select() + + +class MockSPIDevice(object): + def __init__( + self, clock_pin, mosi_pin=None, miso_pin=None, select_pin=None, + clock_polarity=False, clock_phase=False, lsb_first=False, + bits_per_word=8, select_high=False): + self.clock_pin = Device.pin_factory.pin(clock_pin, pin_class=MockSPIClockPin) + self.mosi_pin = None if mosi_pin is None else Device.pin_factory.pin(mosi_pin) + self.miso_pin = None if miso_pin is None else Device.pin_factory.pin(miso_pin) + self.select_pin = None if select_pin is None else Device.pin_factory.pin(select_pin, pin_class=MockSPISelectPin) + self.clock_polarity = clock_polarity + self.clock_phase = clock_phase + self.lsb_first = lsb_first + self.bits_per_word = bits_per_word + self.select_high = select_high + self.rx_bit = 0 + self.rx_buf = [] + self.tx_buf = [] + self.clock_pin.spi_devices.append(self) + self.select_pin.spi_device = self + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.close() + + def close(self): + if self in self.clock_pin.spi_devices: + self.clock_pin.spi_devices.remove(self) + if self.select_pin is not None: + self.select_pin.spi_device = None + + def on_select(self): + if self.select_pin.state == self.select_high: + self.on_start() + + def on_clock(self): + # Don't do anything if this SPI device isn't currently selected + if self.select_pin is None or self.select_pin.state == self.select_high: + # The XOR of the clock pin's values, polarity and phase indicates + # whether we're meant to be acting on this edge + if self.clock_pin.state ^ self.clock_polarity ^ self.clock_phase: + self.rx_bit += 1 + if self.mosi_pin is not None: + self.rx_buf.append(self.mosi_pin.state) + if self.miso_pin is not None: + try: + tx_value = self.tx_buf.pop(0) + except IndexError: + tx_value = 0 + if tx_value: + self.miso_pin.drive_high() + else: + self.miso_pin.drive_low() + self.on_bit() + + def on_start(self): + """ + Override this in descendents to detect when the mock SPI device's + select line is activated. + """ + self.rx_bit = 0 + self.rx_buf = [] + self.tx_buf = [] + + def on_bit(self): + """ + Override this in descendents to react to receiving a bit. + + The :attr:`rx_bit` attribute gives the index of the bit received (this + is reset to 0 by default by :meth:`on_select`). The :attr:`rx_buf` + sequence gives the sequence of 1s and 0s that have been recevied so + far. The :attr:`tx_buf` sequence gives the sequence of 1s and 0s to + transmit on the next clock pulses. All these attributes can be modified + within this method. + + The :meth:`rx_word` and :meth:`tx_word` methods can also be used to + read and append to the buffers using integers instead of bool bits. + """ + pass + + def rx_word(self): + result = 0 + bits = reversed(self.rx_buf) if self.lsb_first else self.rx_buf + for bit in bits: + result <<= 1 + result |= bit + return result + + def tx_word(self, value, bits_per_word=None): + if bits_per_word is None: + bits_per_word = self.bits_per_word + bits = [0] * bits_per_word + for bit in range(bits_per_word): + bits[bit] = value & 1 + value >>= 1 + assert not value + if not self.lsb_first: + bits = reversed(bits) + self.tx_buf.extend(bits) + + +class MockFactory(LocalPiFactory): + """ + Factory for generating mock pins. The *revision* parameter specifies what + revision of Pi the mock factory pretends to be (this affects the result of + the :attr:`pi_info` attribute as well as where pull-ups are assumed to be). + The *pin_class* attribute specifies which mock pin class will be generated + by the :meth:`pin` method by default. This can be changed after + construction by modifying the :attr:`pin_class` attribute. + """ + def __init__( + self, revision=os.getenv('GPIOZERO_MOCK_REVISION', 'a02082'), + pin_class=os.getenv('GPIOZERO_MOCK_PIN_CLASS', MockPin)): + super(MockFactory, self).__init__() + self._revision = revision + if isinstance(pin_class, bytes): + pin_class = pin_class.decode('ascii') + if isinstance(pin_class, str): + dist = pkg_resources.get_distribution('gpiozero') + group = 'gpiozero_mock_pin_classes' + pin_class = pkg_resources.load_entry_point(dist, group, pin_class.lower()) + if not issubclass(pin_class, MockPin): + raise ValueError('invalid mock pin_class: %r' % pin_class) + self.pin_class = pin_class + + def _get_revision(self): + return self._revision + + def reset(self): + """ + Clears the pins and reservations sets. This is primarily useful in + test suites to ensure the pin factory is back in a "clean" state before + the next set of tests are run. + """ + self.pins.clear() + self._reservations.clear() + + def pin(self, spec, pin_class=None, **kwargs): + """ + The pin method for :class:`MockFactory` additionally takes a *pin_class* + attribute which can be used to override the class' :attr:`pin_class` + attribute. Any additional keyword arguments will be passed along to the + pin constructor (useful with things like :class:`MockConnectedPin` which + expect to be constructed with another pin). + """ + if pin_class is None: + pin_class = self.pin_class + n = self._to_gpio(spec) + try: + pin = self.pins[n] + except KeyError: + pin = pin_class(self, n, **kwargs) + self.pins[n] = pin + else: + # Ensure the pin class expected supports PWM (or not) + if issubclass(pin_class, MockPWMPin) != isinstance(pin, MockPWMPin): + raise ValueError('pin %d is already in use as a %s' % (n, pin.__class__.__name__)) + return pin + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/native.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/native.py new file mode 100644 index 00000000..399fdfc4 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/native.py @@ -0,0 +1,335 @@ +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +nstr = str +str = type('') + +import io +import os +import mmap +import errno +import struct +import warnings +from time import sleep +from threading import Thread, Event, Lock +from collections import Counter + +from .local import LocalPiPin, LocalPiFactory +from ..exc import ( + PinInvalidPull, + PinInvalidEdges, + PinInvalidFunction, + PinFixedPull, + PinSetInput, + ) + + +class GPIOMemory(object): + + GPIO_BASE_OFFSET = 0x200000 + PERI_BASE_OFFSET = { + 'BCM2708': 0x20000000, + 'BCM2835': 0x20000000, + 'BCM2709': 0x3f000000, + 'BCM2836': 0x3f000000, + } + + # From BCM2835 data-sheet, p.91 + GPFSEL_OFFSET = 0x00 >> 2 + GPSET_OFFSET = 0x1c >> 2 + GPCLR_OFFSET = 0x28 >> 2 + GPLEV_OFFSET = 0x34 >> 2 + GPEDS_OFFSET = 0x40 >> 2 + GPREN_OFFSET = 0x4c >> 2 + GPFEN_OFFSET = 0x58 >> 2 + GPHEN_OFFSET = 0x64 >> 2 + GPLEN_OFFSET = 0x70 >> 2 + GPAREN_OFFSET = 0x7c >> 2 + GPAFEN_OFFSET = 0x88 >> 2 + GPPUD_OFFSET = 0x94 >> 2 + GPPUDCLK_OFFSET = 0x98 >> 2 + + def __init__(self): + try: + self.fd = os.open('/dev/gpiomem', os.O_RDWR | os.O_SYNC) + except OSError: + try: + self.fd = os.open('/dev/mem', os.O_RDWR | os.O_SYNC) + except OSError: + raise IOError( + 'unable to open /dev/gpiomem or /dev/mem; ' + 'upgrade your kernel or run as root') + else: + offset = self.peripheral_base() + self.GPIO_BASE_OFFSET + else: + offset = 0 + self.mem = mmap.mmap(self.fd, 4096, offset=offset) + + def close(self): + self.mem.close() + os.close(self.fd) + + def peripheral_base(self): + try: + with io.open('/proc/device-tree/soc/ranges', 'rb') as f: + f.seek(4) + return struct.unpack(nstr('>L'), f.read(4))[0] + except IOError: + with io.open('/proc/cpuinfo', 'r') as f: + for line in f: + if line.startswith('Hardware'): + try: + return self.PERI_BASE_OFFSET[line.split(':')[1].strip()] + except KeyError: + raise IOError('unable to determine RPi revision') + raise IOError('unable to determine peripheral base') + + def __getitem__(self, index): + return struct.unpack_from(nstr('> self._func_shift) & 7] + + def _set_function(self, value): + try: + value = self.GPIO_FUNCTIONS[value] + except KeyError: + raise PinInvalidFunction('invalid function "%s" for pin %r' % (value, self)) + self.factory.mem[self._func_offset] = ( + self.factory.mem[self._func_offset] + & ~(7 << self._func_shift) + | (value << self._func_shift) + ) + + def _get_state(self): + return bool(self.factory.mem[self._level_offset] & (1 << self._level_shift)) + + def _set_state(self, value): + if self.function == 'input': + raise PinSetInput('cannot set state of pin %r' % self) + if value: + self.factory.mem[self._set_offset] = 1 << self._set_shift + else: + self.factory.mem[self._clear_offset] = 1 << self._clear_shift + + def _get_pull(self): + return self.GPIO_PULL_UP_NAMES[self._pull] + + def _set_pull(self, value): + if self.function != 'input': + raise PinFixedPull('cannot set pull on non-input pin %r' % self) + if value != 'up' and self.factory.pi_info.pulled_up(repr(self)): + raise PinFixedPull('%r has a physical pull-up resistor' % self) + try: + value = self.GPIO_PULL_UPS[value] + except KeyError: + raise PinInvalidPull('invalid pull direction "%s" for pin %r' % (value, self)) + self.factory.mem[self.factory.mem.GPPUD_OFFSET] = value + sleep(0.000000214) + self.factory.mem[self._pull_offset] = 1 << self._pull_shift + sleep(0.000000214) + self.factory.mem[self.factory.mem.GPPUD_OFFSET] = 0 + self.factory.mem[self._pull_offset] = 0 + self._pull = value + + def _get_edges(self): + rising = bool(self.factory.mem[self._rising_offset] & (1 << self._rising_shift)) + falling = bool(self.factory.mem[self._falling_offset] & (1 << self._falling_shift)) + return self.GPIO_EDGES_NAMES[(rising, falling)] + + def _set_edges(self, value): + try: + rising, falling = self.GPIO_EDGES[value] + except KeyError: + raise PinInvalidEdges('invalid edge specification "%s" for pin %r' % self) + f = self.when_changed + self.when_changed = None + try: + self.factory.mem[self._rising_offset] = ( + self.factory.mem[self._rising_offset] + & ~(1 << self._rising_shift) + | (rising << self._rising_shift) + ) + self.factory.mem[self._falling_offset] = ( + self.factory.mem[self._falling_offset] + & ~(1 << self._falling_shift) + | (falling << self._falling_shift) + ) + finally: + self.when_changed = f + + def _enable_event_detect(self): + self._change_thread = Thread(target=self._change_watch) + self._change_thread.daemon = True + self._change_event.clear() + self._change_thread.start() + + def _disable_event_detect(self): + self._change_event.set() + self._change_thread.join() + self._change_thread = None + + def _change_watch(self): + offset = self._edge_offset + mask = 1 << self._edge_shift + self.factory.mem[offset] = mask # clear any existing detection bit + while not self._change_event.wait(0.001): + if self.factory.mem[offset] & mask: + self.factory.mem[offset] = mask + self._call_when_changed() + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/pi.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/pi.py new file mode 100644 index 00000000..071d959d --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/pi.py @@ -0,0 +1,305 @@ +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + +import io +from threading import RLock, Lock +from types import MethodType +from collections import defaultdict +try: + from weakref import ref, WeakMethod +except ImportError: + + from ..compat import WeakMethod +import warnings + +try: + from spidev import SpiDev +except ImportError: + SpiDev = None + +from . import Factory, Pin +from .data import pi_info +from ..exc import ( + PinNoPins, + PinNonPhysical, + PinInvalidPin, + SPIBadArgs, + SPISoftwareFallback, + ) + + +SPI_HARDWARE_PINS = { + 0: { + 'clock': 11, + 'mosi': 10, + 'miso': 9, + 'select': (8, 7), + }, +} + + +class PiFactory(Factory): + """ + Abstract base class representing hardware attached to a Raspberry Pi. This + forms the base of :class:`~gpiozero.pins.local.LocalPiFactory`. + """ + def __init__(self): + super(PiFactory, self).__init__() + self._info = None + self.pins = {} + self.pin_class = None + self.spi_classes = { + ('hardware', 'exclusive'): None, + ('hardware', 'shared'): None, + ('software', 'exclusive'): None, + ('software', 'shared'): None, + } + + def close(self): + for pin in self.pins.values(): + pin.close() + self.pins.clear() + + def pin(self, spec): + n = self._to_gpio(spec) + try: + pin = self.pins[n] + except KeyError: + pin = self.pin_class(self, n) + self.pins[n] = pin + return pin + + def _to_gpio(self, spec): + """ + Converts the pin *spec* to a GPIO port number. + """ + if not 0 <= spec < 54: + raise PinInvalidPin('invalid GPIO port %d specified (range 0..53) ' % spec) + return spec + + def _get_revision(self): + raise NotImplementedError + + def _get_pi_info(self): + if self._info is None: + self._info = pi_info(self._get_revision()) + return self._info + + def spi(self, **spi_args): + """ + Returns an SPI interface, for the specified SPI *port* and *device*, or + for the specified pins (*clock_pin*, *mosi_pin*, *miso_pin*, and + *select_pin*). Only one of the schemes can be used; attempting to mix + *port* and *device* with pin numbers will raise :exc:`SPIBadArgs`. + + If the pins specified match the hardware SPI pins (clock on GPIO11, + MOSI on GPIO10, MISO on GPIO9, and chip select on GPIO8 or GPIO7), and + the spidev module can be imported, a :class:`SPIHardwareInterface` + instance will be returned. Otherwise, a :class:`SPISoftwareInterface` + will be returned which will use simple bit-banging to communicate. + + Both interfaces have the same API, support clock polarity and phase + attributes, and can handle half and full duplex communications, but the + hardware interface is significantly faster (though for many things this + doesn't matter). + """ + spi_args, kwargs = self._extract_spi_args(**spi_args) + shared = 'shared' if kwargs.pop('shared', False) else 'exclusive' + if kwargs: + raise SPIBadArgs( + 'unrecognized keyword argument %s' % kwargs.popitem()[0]) + for port, pins in SPI_HARDWARE_PINS.items(): + if all(( + spi_args['clock_pin'] == pins['clock'], + spi_args['mosi_pin'] == pins['mosi'], + spi_args['miso_pin'] == pins['miso'], + spi_args['select_pin'] in pins['select'], + )): + try: + return self.spi_classes[('hardware', shared)]( + self, port=port, + device=pins['select'].index(spi_args['select_pin']) + ) + except Exception as e: + warnings.warn( + SPISoftwareFallback( + 'failed to initialize hardware SPI, falling back to ' + 'software (error was: %s)' % str(e))) + break + # Convert all pin arguments to integer GPIO numbers. This is necessary + # to ensure the shared-key for shared implementations get matched + # correctly, and is a bit of a hack for the pigpio bit-bang + # implementation which just wants the pin numbers too. + spi_args = { + key: pin.number if isinstance(pin, Pin) else pin + for key, pin in spi_args.items() + } + return self.spi_classes[('software', shared)](self, **spi_args) + + def _extract_spi_args(self, **kwargs): + """ + Given a set of keyword arguments, splits it into those relevant to SPI + implementations and all the rest. SPI arguments are augmented with + defaults and converted into the pin format (from the port/device + format) if necessary. + + Returns a tuple of ``(spi_args, other_args)``. + """ + dev_defaults = { + 'port': 0, + 'device': 0, + } + default_hw = SPI_HARDWARE_PINS[dev_defaults['port']] + pin_defaults = { + 'clock_pin': default_hw['clock'], + 'mosi_pin': default_hw['mosi'], + 'miso_pin': default_hw['miso'], + 'select_pin': default_hw['select'][dev_defaults['device']], + } + spi_args = { + key: value for (key, value) in kwargs.items() + if key in pin_defaults or key in dev_defaults + } + kwargs = { + key: value for (key, value) in kwargs.items() + if key not in spi_args + } + if not spi_args: + spi_args = pin_defaults + elif set(spi_args) <= set(pin_defaults): + spi_args = { + key: self._to_gpio(spi_args.get(key, default)) + for key, default in pin_defaults.items() + } + elif set(spi_args) <= set(dev_defaults): + spi_args = { + key: spi_args.get(key, default) + for key, default in dev_defaults.items() + } + if spi_args['port'] != 0: + raise SPIBadArgs('port 0 is the only valid SPI port') + selected_hw = SPI_HARDWARE_PINS[spi_args['port']] + try: + selected_hw['select'][spi_args['device']] + except IndexError: + raise SPIBadArgs( + 'device must be in the range 0..%d' % + len(selected_hw['select'])) + spi_args = { + key: value if key != 'select_pin' else selected_hw['select'][spi_args['device']] + for key, value in pin_defaults.items() + } + else: + raise SPIBadArgs( + 'you must either specify port and device, or clock_pin, ' + 'mosi_pin, miso_pin, and select_pin; combinations of the two ' + 'schemes (e.g. port and clock_pin) are not permitted') + return spi_args, kwargs + + +class PiPin(Pin): + """ + Abstract base class representing a multi-function GPIO pin attached to a + Raspberry Pi. This overrides several methods in the abstract base + :class:`~gpiozero.Pin`. Descendents must override the following methods: + + * :meth:`_get_function` + * :meth:`_set_function` + * :meth:`_get_state` + * :meth:`_call_when_changed` + * :meth:`_enable_event_detect` + * :meth:`_disable_event_detect` + + Descendents *may* additionally override the following methods, if + applicable: + + * :meth:`close` + * :meth:`output_with_state` + * :meth:`input_with_pull` + * :meth:`_set_state` + * :meth:`_get_frequency` + * :meth:`_set_frequency` + * :meth:`_get_pull` + * :meth:`_set_pull` + * :meth:`_get_bounce` + * :meth:`_set_bounce` + * :meth:`_get_edges` + * :meth:`_set_edges` + """ + def __init__(self, factory, number): + super(PiPin, self).__init__() + self._factory = factory + self._when_changed_lock = RLock() + self._when_changed = None + self._number = number + try: + factory.pi_info.physical_pin(repr(self)) + except PinNoPins: + warnings.warn( + PinNonPhysical( + 'no physical pins exist for %s' % repr(self))) + + @property + def number(self): + return self._number + + def __repr__(self): + return 'GPIO%d' % self._number + + @property + def factory(self): + return self._factory + + def _call_when_changed(self): + """ + Called to fire the :attr:`when_changed` event handler; override this + in descendents if additional (currently redundant) parameters need + to be passed. + """ + method = self.when_changed() + if method is None: + self.when_changed = None + else: + method() + + def _get_when_changed(self): + return self._when_changed + + def _set_when_changed(self, value): + with self._when_changed_lock: + if value is None: + if self._when_changed is not None: + self._disable_event_detect() + self._when_changed = None + else: + enabled = self._when_changed is not None + # Have to take care, if value is either a closure or a bound + # method, not to keep a strong reference to the containing + # object + if isinstance(value, MethodType): + self._when_changed = WeakMethod(value) + else: + self._when_changed = ref(value) + if not enabled: + self._enable_event_detect() + + def _enable_event_detect(self): + """ + Enables event detection. This is called to activate event detection on + pin :attr:`number`, watching for the specified :attr:`edges`. In + response, :meth:`_call_when_changed` should be executed. + """ + raise NotImplementedError + + def _disable_event_detect(self): + """ + Disables event detection. This is called to deactivate event detection + on pin :attr:`number`. + """ + raise NotImplementedError + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/pigpio.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/pigpio.py new file mode 100644 index 00000000..bc466b9f --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/pigpio.py @@ -0,0 +1,535 @@ +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + +import os + +import pigpio + +from . import SPI +from .pi import PiPin, PiFactory, SPI_HARDWARE_PINS +from .data import pi_info +from ..devices import Device +from ..mixins import SharedMixin +from ..exc import ( + PinInvalidFunction, + PinSetInput, + PinFixedPull, + PinInvalidPull, + PinInvalidBounce, + PinInvalidState, + SPIBadArgs, + SPIInvalidClockMode, + ) + + +class PiGPIOFactory(PiFactory): + """ + Uses the `pigpio`_ library to interface to the Pi's GPIO pins. The pigpio + library relies on a daemon (``pigpiod``) to be running as root to provide + access to the GPIO pins, and communicates with this daemon over a network + socket. + + While this does mean only the daemon itself should control the pins, the + architecture does have several advantages: + + * Pins can be remote controlled from another machine (the other + machine doesn't even have to be a Raspberry Pi; it simply needs the + `pigpio`_ client library installed on it) + * The daemon supports hardware PWM via the DMA controller + * Your script itself doesn't require root privileges; it just needs to + be able to communicate with the daemon + + You can construct pigpio pins manually like so:: + + from gpiozero.pins.pigpio import PiGPIOFactory + from gpiozero import LED + + factory = PiGPIOFactory() + led = LED(12, pin_factory=factory) + + This is particularly useful for controlling pins on a remote machine. To + accomplish this simply specify the host (and optionally port) when + constructing the pin:: + + from gpiozero.pins.pigpio import PiGPIOFactory + from gpiozero import LED + + factory = PiGPIOFactory(host='192.168.0.2') + led = LED(12, pin_factory=factory) + + .. note:: + + In some circumstances, especially when playing with PWM, it does appear + to be possible to get the daemon into "unusual" states. We would be + most interested to hear any bug reports relating to this (it may be a + bug in our pin implementation). A workaround for now is simply to + restart the ``pigpiod`` daemon. + + .. _pigpio: http://abyz.co.uk/rpi/pigpio/ + """ + def __init__( + self, host=os.getenv('PIGPIO_ADDR', 'localhost'), + port=int(os.getenv('PIGPIO_PORT', 8888))): + super(PiGPIOFactory, self).__init__() + self.pin_class = PiGPIOPin + self.spi_classes = { + ('hardware', 'exclusive'): PiGPIOHardwareSPI, + ('hardware', 'shared'): PiGPIOHardwareSPIShared, + ('software', 'exclusive'): PiGPIOSoftwareSPI, + ('software', 'shared'): PiGPIOSoftwareSPIShared, + } + self._connection = pigpio.pi(host, port) + # Annoyingly, pigpio doesn't raise an exception when it fails to make a + # connection; it returns a valid (but disconnected) pi object + if self.connection is None: + raise IOError('failed to connect to %s:%s' % (host, port)) + self._host = host + self._port = port + self._spis = [] + + def close(self): + super(PiGPIOFactory, self).close() + # We *have* to keep track of SPI interfaces constructed with pigpio; + # if we fail to close them they prevent future interfaces from using + # the same pins + if self.connection: + while self._spis: + self._spis[0].close() + self.connection.stop() + self._connection = None + + @property + def connection(self): + # If we're shutting down, the connection may have disconnected itself + # already. Unfortunately, the connection's "connected" property is + # rather buggy - disconnecting doesn't set it to False! So we're + # naughty and check an internal variable instead... + try: + if self._connection.sl.s is not None: + return self._connection + except AttributeError: + pass + + @property + def host(self): + return self._host + + @property + def port(self): + return self._port + + def _get_revision(self): + return self.connection.get_hardware_revision() + + def spi(self, **spi_args): + intf = super(PiGPIOFactory, self).spi(**spi_args) + self._spis.append(intf) + return intf + + +class PiGPIOPin(PiPin): + """ + Pin implementation for the `pigpio`_ library. See :class:`PiGPIOFactory` + for more information. + + .. _pigpio: http://abyz.co.uk/rpi/pigpio/ + """ + _CONNECTIONS = {} # maps (host, port) to (connection, pi_info) + GPIO_FUNCTIONS = { + 'input': pigpio.INPUT, + 'output': pigpio.OUTPUT, + 'alt0': pigpio.ALT0, + 'alt1': pigpio.ALT1, + 'alt2': pigpio.ALT2, + 'alt3': pigpio.ALT3, + 'alt4': pigpio.ALT4, + 'alt5': pigpio.ALT5, + } + + GPIO_PULL_UPS = { + 'up': pigpio.PUD_UP, + 'down': pigpio.PUD_DOWN, + 'floating': pigpio.PUD_OFF, + } + + GPIO_EDGES = { + 'both': pigpio.EITHER_EDGE, + 'rising': pigpio.RISING_EDGE, + 'falling': pigpio.FALLING_EDGE, + } + + GPIO_FUNCTION_NAMES = {v: k for (k, v) in GPIO_FUNCTIONS.items()} + GPIO_PULL_UP_NAMES = {v: k for (k, v) in GPIO_PULL_UPS.items()} + GPIO_EDGES_NAMES = {v: k for (k, v) in GPIO_EDGES.items()} + + def __init__(self, factory, number): + super(PiGPIOPin, self).__init__(factory, number) + self._pull = 'up' if self.factory.pi_info.pulled_up(repr(self)) else 'floating' + self._pwm = False + self._bounce = None + self._callback = None + self._edges = pigpio.EITHER_EDGE + try: + self.factory.connection.set_mode(self.number, pigpio.INPUT) + except pigpio.error as e: + raise ValueError(e) + self.factory.connection.set_pull_up_down(self.number, self.GPIO_PULL_UPS[self._pull]) + self.factory.connection.set_glitch_filter(self.number, 0) + + def close(self): + if self.factory.connection: + self.frequency = None + self.when_changed = None + self.function = 'input' + self.pull = 'up' if self.factory.pi_info.pulled_up(repr(self)) else 'floating' + + def _get_function(self): + return self.GPIO_FUNCTION_NAMES[self.factory.connection.get_mode(self.number)] + + def _set_function(self, value): + if value != 'input': + self._pull = 'floating' + try: + self.factory.connection.set_mode(self.number, self.GPIO_FUNCTIONS[value]) + except KeyError: + raise PinInvalidFunction('invalid function "%s" for pin %r' % (value, self)) + + def _get_state(self): + if self._pwm: + return ( + self.factory.connection.get_PWM_dutycycle(self.number) / + self.factory.connection.get_PWM_range(self.number) + ) + else: + return bool(self.factory.connection.read(self.number)) + + def _set_state(self, value): + if self._pwm: + try: + value = int(value * self.factory.connection.get_PWM_range(self.number)) + if value != self.factory.connection.get_PWM_dutycycle(self.number): + self.factory.connection.set_PWM_dutycycle(self.number, value) + except pigpio.error: + raise PinInvalidState('invalid state "%s" for pin %r' % (value, self)) + elif self.function == 'input': + raise PinSetInput('cannot set state of pin %r' % self) + else: + # write forces pin to OUTPUT, hence the check above + self.factory.connection.write(self.number, bool(value)) + + def _get_pull(self): + return self._pull + + def _set_pull(self, value): + if self.function != 'input': + raise PinFixedPull('cannot set pull on non-input pin %r' % self) + if value != 'up' and self.factory.pi_info.pulled_up(repr(self)): + raise PinFixedPull('%r has a physical pull-up resistor' % self) + try: + self.factory.connection.set_pull_up_down(self.number, self.GPIO_PULL_UPS[value]) + self._pull = value + except KeyError: + raise PinInvalidPull('invalid pull "%s" for pin %r' % (value, self)) + + def _get_frequency(self): + if self._pwm: + return self.factory.connection.get_PWM_frequency(self.number) + return None + + def _set_frequency(self, value): + if not self._pwm and value is not None: + if self.function != 'output': + raise PinPWMFixedValue('cannot start PWM on pin %r' % self) + # NOTE: the pin's state *must* be set to zero; if it's currently + # high, starting PWM and setting a 0 duty-cycle *doesn't* bring + # the pin low; it stays high! + self.factory.connection.write(self.number, 0) + self.factory.connection.set_PWM_frequency(self.number, value) + self.factory.connection.set_PWM_range(self.number, 10000) + self.factory.connection.set_PWM_dutycycle(self.number, 0) + self._pwm = True + elif self._pwm and value is not None: + if value != self.factory.connection.get_PWM_frequency(self.number): + self.factory.connection.set_PWM_frequency(self.number, value) + self.factory.connection.set_PWM_range(self.number, 10000) + elif self._pwm and value is None: + self.factory.connection.write(self.number, 0) + self._pwm = False + + def _get_bounce(self): + return None if not self._bounce else self._bounce / 1000000 + + def _set_bounce(self, value): + if value is None: + value = 0 + elif value < 0: + raise PinInvalidBounce('bounce must be 0 or greater') + self.factory.connection.set_glitch_filter(self.number, int(value * 1000000)) + + def _get_edges(self): + return self.GPIO_EDGES_NAMES[self._edges] + + def _set_edges(self, value): + f = self.when_changed + self.when_changed = None + try: + self._edges = self.GPIO_EDGES[value] + finally: + self.when_changed = f + + def _call_when_changed(self, gpio, level, tick): + super(PiGPIOPin, self)._call_when_changed() + + def _enable_event_detect(self): + self._callback = self.factory.connection.callback( + self.number, self._edges, self._call_when_changed) + + def _disable_event_detect(self): + if self._callback is not None: + self._callback.cancel() + self._callback = None + + +class PiGPIOHardwareSPI(SPI, Device): + """ + Hardware SPI implementation for the `pigpio`_ library. Uses the ``spi_*`` + functions from the pigpio API. + + .. _pigpio: http://abyz.co.uk/rpi/pigpio/ + """ + def __init__(self, factory, port, device): + self._port = port + self._device = device + self._factory = factory + self._handle = None + super(PiGPIOHardwareSPI, self).__init__() + pins = SPI_HARDWARE_PINS[port] + self._factory.reserve_pins( + self, + pins['clock'], + pins['mosi'], + pins['miso'], + pins['select'][device] + ) + self._spi_flags = 8 << 16 + self._baud = 500000 + self._handle = self._factory.connection.spi_open( + device, self._baud, self._spi_flags) + + def _conflicts_with(self, other): + return not ( + isinstance(other, PiGPIOHardwareSPI) and + (self._port, self._device) != (other._port, other._device) + ) + + def close(self): + try: + self._factory._spis.remove(self) + except (ReferenceError, ValueError): + # If the factory has died already or we're not present in its + # internal list, ignore the error + pass + if not self.closed: + self._factory.connection.spi_close(self._handle) + self._handle = None + self._factory.release_all(self) + super(PiGPIOHardwareSPI, self).close() + + @property + def closed(self): + return self._handle is None or self._factory.connection is None + + @property + def factory(self): + return self._factory + + def __repr__(self): + try: + self._check_open() + return 'SPI(port=%d, device=%d)' % (self._port, self._device) + except DeviceClosed: + return 'SPI(closed)' + + def _get_clock_mode(self): + return self._spi_flags & 0x3 + + def _set_clock_mode(self, value): + self._check_open() + if not 0 <= value < 4: + raise SPIInvalidClockMode("%d is not a valid SPI clock mode" % value) + self._factory.connection.spi_close(self._handle) + self._spi_flags = (self._spi_flags & ~0x3) | value + self._handle = self._factory.connection.spi_open( + self._device, self._baud, self._spi_flags) + + def _get_select_high(self): + return bool((self._spi_flags >> (2 + self._device)) & 0x1) + + def _set_select_high(self, value): + self._check_open() + self._factory.connection.spi_close(self._handle) + self._spi_flags = (self._spi_flags & ~0x1c) | (bool(value) << (2 + self._device)) + self._handle = self._factory.connection.spi_open( + self._device, self._baud, self._spi_flags) + + def _get_bits_per_word(self): + return (self._spi_flags >> 16) & 0x3f + + def _set_bits_per_word(self, value): + self._check_open() + self._factory.connection.spi_close(self._handle) + self._spi_flags = (self._spi_flags & ~0x3f0000) | ((value & 0x3f) << 16) + self._handle = self._factory.connection.spi_open( + self._device, self._baud, self._spi_flags) + + def transfer(self, data): + self._check_open() + count, data = self._factory.connection.spi_xfer(self._handle, data) + if count < 0: + raise IOError('SPI transfer error %d' % count) + # Convert returned bytearray to list of ints. XXX Not sure how non-byte + # sized words (aux intf only) are returned ... padded to 16/32-bits? + return [int(b) for b in data] + + +class PiGPIOSoftwareSPI(SPI, Device): + """ + Software SPI implementation for the `pigpio`_ library. Uses the ``bb_spi_*`` + functions from the pigpio API. + + .. _pigpio: http://abyz.co.uk/rpi/pigpio/ + """ + def __init__(self, factory, clock_pin, mosi_pin, miso_pin, select_pin): + self._closed = True + self._select_pin = select_pin + self._clock_pin = clock_pin + self._mosi_pin = mosi_pin + self._miso_pin = miso_pin + self._factory = factory + super(PiGPIOSoftwareSPI, self).__init__() + self._factory.reserve_pins( + self, + clock_pin, + mosi_pin, + miso_pin, + select_pin, + ) + self._spi_flags = 0 + self._baud = 100000 + try: + self._factory.connection.bb_spi_open( + select_pin, miso_pin, mosi_pin, clock_pin, + self._baud, self._spi_flags) + # Only set after opening bb_spi; if that fails then close() will + # also fail if bb_spi_close is attempted on an un-open interface + self._closed = False + except: + self.close() + raise + + def _conflicts_with(self, other): + return not ( + isinstance(other, PiGPIOSoftwareSPI) and + (self._select_pin) != (other._select_pin) + ) + + def close(self): + try: + self._factory._spis.remove(self) + except (ReferenceError, ValueError): + # If the factory has died already or we're not present in its + # internal list, ignore the error + pass + if not self.closed: + self._closed = True + self._factory.connection.bb_spi_close(self._select_pin) + self.factory.release_all(self) + super(PiGPIOSoftwareSPI, self).close() + + @property + def closed(self): + return self._closed + + def __repr__(self): + try: + self._check_open() + return ( + 'SPI(clock_pin=%d, mosi_pin=%d, miso_pin=%d, select_pin=%d)' % ( + self._clock_pin, self._mosi_pin, self._miso_pin, self._select_pin + )) + except DeviceClosed: + return 'SPI(closed)' + + def _spi_flags(self): + return ( + self._mode << 0 | + self._select_high << 2 | + self._lsb_first << 14 | + self._lsb_first << 15 + ) + + def _get_clock_mode(self): + return self._spi_flags & 0x3 + + def _set_clock_mode(self, value): + self._check_open() + if not 0 <= value < 4: + raise SPIInvalidClockmode("%d is not a valid SPI clock mode" % value) + self._factory.connection.bb_spi_close(self._select_pin) + self._spi_flags = (self._spi_flags & ~0x3) | value + self._factory.connection.bb_spi_open( + self._select_pin, self._miso_pin, self._mosi_pin, self._clock_pin, + self._baud, self._spi_flags) + + def _get_select_high(self): + return bool(self._spi_flags & 0x4) + + def _set_select_high(self, value): + self._check_open() + self._factory.connection.bb_spi_close(self._select_pin) + self._spi_flags = (self._spi_flags & ~0x4) | (bool(value) << 2) + self._factory.connection.bb_spi_open( + self._select_pin, self._miso_pin, self._mosi_pin, self._clock_pin, + self._baud, self._spi_flags) + + def _get_lsb_first(self): + return bool(self._spi_flags & 0xc000) + + def _set_lsb_first(self, value): + self._check_open() + self._factory.connection.bb_spi_close(self._select_pin) + self._spi_flags = ( + (self._spi_flags & ~0xc000) + | (bool(value) << 14) + | (bool(value) << 15) + ) + self._factory.connection.bb_spi_open( + self._select_pin, self._miso_pin, self._mosi_pin, self._clock_pin, + self._baud, self._spi_flags) + + def transfer(self, data): + self._check_open() + count, data = self._factory.connection.bb_spi_xfer(self._select_pin, data) + if count < 0: + raise IOError('SPI transfer error %d' % count) + # Convert returned bytearray to list of ints. bb_spi only supports + # byte-sized words so no issues here + return [int(b) for b in data] + + +class PiGPIOHardwareSPIShared(SharedMixin, PiGPIOHardwareSPI): + @classmethod + def _shared_key(cls, factory, port, device): + return (factory, port, device) + + +class PiGPIOSoftwareSPIShared(SharedMixin, PiGPIOSoftwareSPI): + @classmethod + def _shared_key(cls, factory, clock_pin, mosi_pin, miso_pin, select_pin): + return (factory, select_pin) + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/rpigpio.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/rpigpio.py new file mode 100644 index 00000000..300ac658 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/rpigpio.py @@ -0,0 +1,223 @@ +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + +import warnings + +from RPi import GPIO + +from .local import LocalPiFactory, LocalPiPin +from ..exc import ( + PinInvalidFunction, + PinSetInput, + PinFixedPull, + PinInvalidPull, + PinInvalidState, + PinInvalidBounce, + PinPWMFixedValue, + ) + + +class RPiGPIOFactory(LocalPiFactory): + """ + Uses the `RPi.GPIO`_ library to interface to the Pi's GPIO pins. This is + the default pin implementation if the RPi.GPIO library is installed. + Supports all features including PWM (via software). + + Because this is the default pin implementation you can use it simply by + specifying an integer number for the pin in most operations, e.g.:: + + from gpiozero import LED + + led = LED(12) + + However, you can also construct RPi.GPIO pins manually if you wish:: + + from gpiozero.pins.rpigpio import RPiGPIOFactory + from gpiozero import LED + + factory = RPiGPIOFactory() + led = LED(12, pin_factory=factory) + + .. _RPi.GPIO: https://pypi.python.org/pypi/RPi.GPIO + """ + + def __init__(self): + super(RPiGPIOFactory, self).__init__() + GPIO.setmode(GPIO.BCM) + GPIO.setwarnings(False) + self.pin_class = RPiGPIOPin + + def close(self): + super(RPiGPIOFactory, self).close() + GPIO.cleanup() + + +class RPiGPIOPin(LocalPiPin): + """ + Pin implementation for the `RPi.GPIO`_ library. See :class:`RPiGPIOFactory` + for more information. + + .. _RPi.GPIO: https://pypi.python.org/pypi/RPi.GPIO + """ + GPIO_FUNCTIONS = { + 'input': GPIO.IN, + 'output': GPIO.OUT, + 'i2c': GPIO.I2C, + 'spi': GPIO.SPI, + 'pwm': GPIO.HARD_PWM, + 'serial': GPIO.SERIAL, + 'unknown': GPIO.UNKNOWN, + } + + GPIO_PULL_UPS = { + 'up': GPIO.PUD_UP, + 'down': GPIO.PUD_DOWN, + 'floating': GPIO.PUD_OFF, + } + + GPIO_EDGES = { + 'both': GPIO.BOTH, + 'rising': GPIO.RISING, + 'falling': GPIO.FALLING, + } + + GPIO_FUNCTION_NAMES = {v: k for (k, v) in GPIO_FUNCTIONS.items()} + GPIO_PULL_UP_NAMES = {v: k for (k, v) in GPIO_PULL_UPS.items()} + GPIO_EDGES_NAMES = {v: k for (k, v) in GPIO_EDGES.items()} + + def __init__(self, factory, number): + super(RPiGPIOPin, self).__init__(factory, number) + self._pull = 'up' if self.factory.pi_info.pulled_up(repr(self)) else 'floating' + self._pwm = None + self._frequency = None + self._duty_cycle = None + self._bounce = -666 + self._edges = GPIO.BOTH + GPIO.setup(self.number, GPIO.IN, self.GPIO_PULL_UPS[self._pull]) + + def close(self): + self.frequency = None + self.when_changed = None + GPIO.cleanup(self.number) + + def output_with_state(self, state): + self._pull = 'floating' + GPIO.setup(self.number, GPIO.OUT, initial=state) + + def input_with_pull(self, pull): + if pull != 'up' and self.factory.pi_info.pulled_up(repr(self)): + raise PinFixedPull('%r has a physical pull-up resistor' % self) + try: + GPIO.setup(self.number, GPIO.IN, self.GPIO_PULL_UPS[pull]) + self._pull = pull + except KeyError: + raise PinInvalidPull('invalid pull "%s" for pin %r' % (pull, self)) + + def _get_function(self): + return self.GPIO_FUNCTION_NAMES[GPIO.gpio_function(self.number)] + + def _set_function(self, value): + if value != 'input': + self._pull = 'floating' + if value in ('input', 'output') and value in self.GPIO_FUNCTIONS: + GPIO.setup(self.number, self.GPIO_FUNCTIONS[value], self.GPIO_PULL_UPS[self._pull]) + else: + raise PinInvalidFunction('invalid function "%s" for pin %r' % (value, self)) + + def _get_state(self): + if self._pwm: + return self._duty_cycle + else: + return GPIO.input(self.number) + + def _set_state(self, value): + if self._pwm: + try: + self._pwm.ChangeDutyCycle(value * 100) + except ValueError: + raise PinInvalidState('invalid state "%s" for pin %r' % (value, self)) + self._duty_cycle = value + else: + try: + GPIO.output(self.number, value) + except ValueError: + raise PinInvalidState('invalid state "%s" for pin %r' % (value, self)) + except RuntimeError: + raise PinSetInput('cannot set state of pin %r' % self) + + def _get_pull(self): + return self._pull + + def _set_pull(self, value): + if self.function != 'input': + raise PinFixedPull('cannot set pull on non-input pin %r' % self) + if value != 'up' and self.factory.pi_info.pulled_up(repr(self)): + raise PinFixedPull('%r has a physical pull-up resistor' % self) + try: + GPIO.setup(self.number, GPIO.IN, self.GPIO_PULL_UPS[value]) + self._pull = value + except KeyError: + raise PinInvalidPull('invalid pull "%s" for pin %r' % (value, self)) + + def _get_frequency(self): + return self._frequency + + def _set_frequency(self, value): + if self._frequency is None and value is not None: + try: + self._pwm = GPIO.PWM(self.number, value) + except RuntimeError: + raise PinPWMFixedValue('cannot start PWM on pin %r' % self) + self._pwm.start(0) + self._duty_cycle = 0 + self._frequency = value + elif self._frequency is not None and value is not None: + self._pwm.ChangeFrequency(value) + self._frequency = value + elif self._frequency is not None and value is None: + self._pwm.stop() + self._pwm = None + self._duty_cycle = None + self._frequency = None + + def _get_bounce(self): + return None if self._bounce == -666 else (self._bounce / 1000) + + def _set_bounce(self, value): + if value is not None and value < 0: + raise PinInvalidBounce('bounce must be 0 or greater') + f = self.when_changed + self.when_changed = None + try: + self._bounce = -666 if value is None else int(value * 1000) + finally: + self.when_changed = f + + def _get_edges(self): + return self.GPIO_EDGES_NAMES[self._edges] + + def _set_edges(self, value): + f = self.when_changed + self.when_changed = None + try: + self._edges = self.GPIO_EDGES[value] + finally: + self.when_changed = f + + def _call_when_changed(self, channel): + super(RPiGPIOPin, self)._call_when_changed() + + def _enable_event_detect(self): + GPIO.add_event_detect( + self.number, self._edges, + callback=self._call_when_changed, + bouncetime=self._bounce) + + def _disable_event_detect(self): + GPIO.remove_event_detect(self.number) + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/rpio.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/rpio.py new file mode 100644 index 00000000..ab9eecfe --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/rpio.py @@ -0,0 +1,217 @@ +from __future__ import ( + unicode_literals, + absolute_import, + print_function, + division, + ) +str = type('') + + +import warnings + +import RPIO +import RPIO.PWM +from RPIO.Exceptions import InvalidChannelException + +from .local import LocalPiPin, LocalPiFactory +from .data import pi_info +from ..exc import ( + PinInvalidFunction, + PinSetInput, + PinFixedPull, + PinInvalidPull, + PinInvalidBounce, + PinInvalidState, + PinPWMError, + ) + + +class RPIOFactory(LocalPiFactory): + """ + Uses the `RPIO`_ library to interface to the Pi's GPIO pins. This is + the default pin implementation if the RPi.GPIO library is not installed, + but RPIO is. Supports all features including PWM (hardware via DMA). + + .. note:: + + Please note that at the time of writing, RPIO is only compatible with + Pi 1's; the Raspberry Pi 2 Model B is *not* supported. Also note that + root access is required so scripts must typically be run with ``sudo``. + + You can construct RPIO pins manually like so:: + + from gpiozero.pins.rpio import RPIOFactory + from gpiozero import LED + + factory = RPIOFactory() + led = LED(12, pin_factory=factory) + + .. _RPIO: https://pythonhosted.org/RPIO/ + """ + def __init__(self): + super(RPIOFactory, self).__init__() + RPIO.setmode(RPIO.BCM) + RPIO.setwarnings(False) + RPIO.wait_for_interrupts(threaded=True) + RPIO.PWM.setup() + RPIO.PWM.init_channel(0, 10000) + self.pin_class = RPIOPin + + def close(self): + RPIO.PWM.cleanup() + RPIO.stop_waiting_for_interrupts() + RPIO.cleanup() + + +class RPIOPin(LocalPiPin): + """ + Pin implementation for the `RPIO`_ library. See :class:`RPIOFactory` for + more information. + + .. _RPIO: https://pythonhosted.org/RPIO/ + """ + GPIO_FUNCTIONS = { + 'input': RPIO.IN, + 'output': RPIO.OUT, + 'alt0': RPIO.ALT0, + } + + GPIO_PULL_UPS = { + 'up': RPIO.PUD_UP, + 'down': RPIO.PUD_DOWN, + 'floating': RPIO.PUD_OFF, + } + + GPIO_FUNCTION_NAMES = {v: k for (k, v) in GPIO_FUNCTIONS.items()} + GPIO_PULL_UP_NAMES = {v: k for (k, v) in GPIO_PULL_UPS.items()} + + def __init__(self, factory, number): + super(RPIOPin, self).__init__(factory, number) + self._pull = 'up' if self.factory.pi_info.pulled_up(repr(self)) else 'floating' + self._pwm = False + self._duty_cycle = None + self._bounce = None + self._edges = 'both' + try: + RPIO.setup(self.number, RPIO.IN, self.GPIO_PULL_UPS[self._pull]) + except InvalidChannelException as e: + raise ValueError(e) + + def close(self): + self.frequency = None + self.when_changed = None + RPIO.setup(self.number, RPIO.IN, RPIO.PUD_OFF) + + def _get_function(self): + return self.GPIO_FUNCTION_NAMES[RPIO.gpio_function(self.number)] + + def _set_function(self, value): + if value != 'input': + self._pull = 'floating' + try: + RPIO.setup(self.number, self.GPIO_FUNCTIONS[value], self.GPIO_PULL_UPS[self._pull]) + except KeyError: + raise PinInvalidFunction('invalid function "%s" for pin %r' % (value, self)) + + def _get_state(self): + if self._pwm: + return self._duty_cycle + else: + return RPIO.input(self.number) + + def _set_state(self, value): + if not 0 <= value <= 1: + raise PinInvalidState('invalid state "%s" for pin %r' % (value, self)) + if self._pwm: + RPIO.PWM.clear_channel_gpio(0, self.number) + if value == 0: + RPIO.output(self.number, False) + elif value == 1: + RPIO.output(self.number, True) + else: + RPIO.PWM.add_channel_pulse(0, self.number, start=0, width=int(1000 * value)) + self._duty_cycle = value + else: + try: + RPIO.output(self.number, value) + except ValueError: + raise PinInvalidState('invalid state "%s" for pin %r' % (value, self)) + except RuntimeError: + raise PinSetInput('cannot set state of pin %r' % self) + + def _get_pull(self): + return self._pull + + def _set_pull(self, value): + if self.function != 'input': + raise PinFixedPull('cannot set pull on non-input pin %r' % self) + if value != 'up' and self.factory.pi_info.pulled_up(repr(self)): + raise PinFixedPull('%r has a physical pull-up resistor' % self) + try: + RPIO.setup(self.number, RPIO.IN, self.GPIO_PULL_UPS[value]) + self._pull = value + except KeyError: + raise PinInvalidPull('invalid pull "%s" for pin %r' % (value, self)) + + def _get_frequency(self): + if self._pwm: + return 100 + else: + return None + + def _set_frequency(self, value): + if value is not None and value != 100: + raise PinPWMError( + 'RPIOPin implementation is currently limited to ' + '100Hz sub-cycles') + if not self._pwm and value is not None: + self._pwm = True + # Dirty hack to get RPIO's PWM support to setup, but do nothing, + # for a given GPIO pin + RPIO.PWM.add_channel_pulse(0, self.number, start=0, width=0) + RPIO.PWM.clear_channel_gpio(0, self.number) + elif self._pwm and value is None: + RPIO.PWM.clear_channel_gpio(0, self.number) + self._pwm = False + + def _get_bounce(self): + return None if self._bounce is None else (self._bounce / 1000) + + def _set_bounce(self, value): + if value is not None and value < 0: + raise PinInvalidBounce('bounce must be 0 or greater') + f = self.when_changed + self.when_changed = None + try: + self._bounce = None if value is None else int(value * 1000) + finally: + self.when_changed = f + + def _get_edges(self): + return self._edges + + def _set_edges(self, value): + f = self.when_changed + self.when_changed = None + try: + self._edges = value + finally: + self.when_changed = f + + def _call_when_changed(self, channel, value): + super(RPIOPin, self)._call_when_changed() + + def _enable_event_detect(self): + RPIO.add_interrupt_callback( + self.number, self._call_when_changed, self._edges, + self.GPIO_PULL_UPS[self._pull], self._bounce) + + def _disable_event_detect(self): + try: + RPIO.del_interrupt_callback(self.number) + except KeyError: + # Ignore this exception which occurs during shutdown; this + # simply means RPIO's built-in cleanup has already run and + # removed the handler + pass + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/spi.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/spi.py new file mode 100644 index 00000000..025b7425 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/pins/spi.py @@ -0,0 +1,96 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, + ) +str = type('') + + +import operator +from threading import RLock + +from ..devices import Device, SharedMixin +from ..input_devices import InputDevice +from ..output_devices import OutputDevice + + +class SPISoftwareBus(SharedMixin, Device): + def __init__(self, clock_pin, mosi_pin, miso_pin): + self.lock = None + self.clock = None + self.mosi = None + self.miso = None + super(SPISoftwareBus, self).__init__() + self.lock = RLock() + try: + self.clock = OutputDevice(clock_pin, active_high=True) + if mosi_pin is not None: + self.mosi = OutputDevice(mosi_pin) + if miso_pin is not None: + self.miso = InputDevice(miso_pin) + except: + self.close() + raise + + def close(self): + super(SPISoftwareBus, self).close() + if getattr(self, 'lock', None): + with self.lock: + if self.miso is not None: + self.miso.close() + self.miso = None + if self.mosi is not None: + self.mosi.close() + self.mosi = None + if self.clock is not None: + self.clock.close() + self.clock = None + self.lock = None + + @property + def closed(self): + return self.lock is None + + @classmethod + def _shared_key(cls, clock_pin, mosi_pin, miso_pin): + return (clock_pin, mosi_pin, miso_pin) + + def transfer(self, data, clock_phase=False, lsb_first=False, bits_per_word=8): + """ + Writes data (a list of integer words where each word is assumed to have + :attr:`bits_per_word` bits or less) to the SPI interface, and reads an + equivalent number of words, returning them as a list of integers. + """ + result = [] + with self.lock: + # See https://en.wikipedia.org/wiki/Serial_Peripheral_Interface_Bus + # (specifically the section "Example of bit-banging the master + # protocol") for a simpler C implementation of this which ignores + # clock polarity, phase, variable word-size, and multiple input + # words + if lsb_first: + shift = operator.lshift + init_mask = 1 + else: + shift = operator.rshift + init_mask = 1 << (bits_per_word - 1) + for write_word in data: + mask = init_mask + read_word = 0 + for _ in range(bits_per_word): + if self.mosi is not None: + self.mosi.value = bool(write_word & mask) + # read bit on clock activation + self.clock.on() + if not clock_phase: + if self.miso is not None and self.miso.value: + read_word |= mask + # read bit on clock deactivation + self.clock.off() + if clock_phase: + if self.miso is not None and self.miso.value: + read_word |= mask + mask = shift(mask, 1) + result.append(read_word) + return result diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/spi_devices.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/spi_devices.py new file mode 100644 index 00000000..e9870bb3 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/spi_devices.py @@ -0,0 +1,547 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, + ) +str = type('') + + +from math import log, ceil +from operator import or_ +try: + from functools import reduce +except ImportError: + pass # py2's reduce is built-in + +from .exc import DeviceClosed, SPIBadChannel +from .devices import Device + + +class SPIDevice(Device): + """ + Extends :class:`Device`. Represents a device that communicates via the SPI + protocol. + + See :ref:`spi_args` for information on the keyword arguments that can be + specified with the constructor. + """ + def __init__(self, **spi_args): + self._spi = None + super(SPIDevice, self).__init__( + pin_factory=spi_args.pop('pin_factory', None) + ) + self._spi = self.pin_factory.spi(**spi_args) + + def close(self): + if getattr(self, '_spi', None): + self._spi.close() + self._spi = None + super(SPIDevice, self).close() + + @property + def closed(self): + return self._spi is None + + def _int_to_words(self, pattern): + """ + Given a bit-pattern expressed an integer number, return a sequence of + the individual words that make up the pattern. The number of bits per + word will be obtained from the internal SPI interface. + """ + try: + bits_required = int(ceil(log(pattern, 2))) + 1 + except ValueError: + # pattern == 0 (technically speaking, no bits are required to + # transmit the value zero ;) + bits_required = 1 + shifts = range(0, bits_required, self._spi.bits_per_word)[::-1] + mask = 2 ** self._spi.bits_per_word - 1 + return [(pattern >> shift) & mask for shift in shifts] + + def _words_to_int(self, words, expected_bits=None): + """ + Given a sequence of words which each fit in the internal SPI + interface's number of bits per word, returns the value obtained by + concatenating each word into a single bit-string. + + If *expected_bits* is specified, it limits the size of the output to + the specified number of bits (by masking off bits above the expected + number). If unspecified, no limit will be applied. + """ + if expected_bits is None: + expected_bits = len(words) * self._spi.bits_per_word + shifts = range(0, expected_bits, self._spi.bits_per_word)[::-1] + mask = 2 ** expected_bits - 1 + return reduce(or_, (word << shift for word, shift in zip(words, shifts))) & mask + + def __repr__(self): + try: + self._check_open() + return "" % (self.__class__.__name__, self._spi) + except DeviceClosed: + return "" % self.__class__.__name__ + + +class AnalogInputDevice(SPIDevice): + """ + Represents an analog input device connected to SPI (serial interface). + + Typical analog input devices are `analog to digital converters`_ (ADCs). + Several classes are provided for specific ADC chips, including + :class:`MCP3004`, :class:`MCP3008`, :class:`MCP3204`, and :class:`MCP3208`. + + The following code demonstrates reading the first channel of an MCP3008 + chip attached to the Pi's SPI pins:: + + from gpiozero import MCP3008 + + pot = MCP3008(0) + print(pot.value) + + The :attr:`value` attribute is normalized such that its value is always + between 0.0 and 1.0 (or in special cases, such as differential sampling, + -1 to +1). Hence, you can use an analog input to control the brightness of + a :class:`PWMLED` like so:: + + from gpiozero import MCP3008, PWMLED + + pot = MCP3008(0) + led = PWMLED(17) + led.source = pot.values + + The :attr:`voltage` attribute reports values between 0.0 and *max_voltage* + (which defaults to 3.3, the logic level of the GPIO pins). + + .. _analog to digital converters: https://en.wikipedia.org/wiki/Analog-to-digital_converter + """ + + def __init__(self, bits, max_voltage=3.3, **spi_args): + if bits is None: + raise InputDeviceError('you must specify the bit resolution of the device') + self._bits = bits + self._min_value = -(2 ** bits) + self._range = 2 ** (bits + 1) - 1 + if max_voltage <= 0: + raise InputDeviceError('max_voltage must be positive') + self._max_voltage = float(max_voltage) + super(AnalogInputDevice, self).__init__(shared=True, **spi_args) + + @property + def bits(self): + """ + The bit-resolution of the device/channel. + """ + return self._bits + + def _read(self): + raise NotImplementedError + + @property + def value(self): + """ + The current value read from the device, scaled to a value between 0 and + 1 (or -1 to +1 for certain devices operating in differential mode). + """ + return (2 * (self._read() - self._min_value) / self._range) - 1 + + @property + def raw_value(self): + """ + The raw value as read from the device. + """ + return self._read() + + @property + def max_voltage(self): + """ + The voltage required to set the device's value to 1. + """ + return self._max_voltage + + @property + def voltage(self): + """ + The current voltage read from the device. This will be a value between + 0 and the *max_voltage* parameter specified in the constructor. + """ + return self.value * self._max_voltage + + +class MCP3xxx(AnalogInputDevice): + """ + Extends :class:`AnalogInputDevice` to implement an interface for all ADC + chips with a protocol similar to the Microchip MCP3xxx series of devices. + """ + + def __init__(self, channel=0, bits=10, differential=False, max_voltage=3.3, + **spi_args): + self._channel = channel + self._differential = bool(differential) + super(MCP3xxx, self).__init__(bits, max_voltage, **spi_args) + + @property + def channel(self): + """ + The channel to read data from. The MCP3008/3208/3304 have 8 channels + (0-7), while the MCP3004/3204/3302 have 4 channels (0-3), the + MCP3002/3202 have 2 channels (0-1), and the MCP3001/3201/3301 only + have 1 channel. + """ + return self._channel + + @property + def differential(self): + """ + If ``True``, the device is operated in differential mode. In this mode + one channel (specified by the channel attribute) is read relative to + the value of a second channel (implied by the chip's design). + + Please refer to the device data-sheet to determine which channel is + used as the relative base value (for example, when using an + :class:`MCP3008` in differential mode, channel 0 is read relative to + channel 1). + """ + return self._differential + + def _read(self): + return self._words_to_int( + self._spi.transfer(self._send())[-2:], self.bits + ) + + def _send(self): + # MCP3004/08 protocol looks like the following: + # + # Byte 0 1 2 + # ==== ======== ======== ======== + # Tx 00000001 MCCCxxxx xxxxxxxx + # Rx xxxxxxxx xxxxx0RR RRRRRRRR + # + # MCP3204/08 protocol looks like the following: + # + # Byte 0 1 2 + # ==== ======== ======== ======== + # Tx 000001MC CCxxxxxx xxxxxxxx + # Rx xxxxxxxx xxx0RRRR RRRRRRRR + # + # The transmit bits start with several preamble "0" bits, the number + # of which is determined by the amount required to align the last byte + # of the result with the final byte of output. A start "1" bit is then + # transmitted, followed by the single/differential bit (M); 1 for + # single-ended read, 0 for differential read. Next comes three bits for + # channel (C). + # + # Read-out begins with a don't care bit (x), then a null bit (0) + # followed by the result bits (R). All other bits are don't care (x). + # + # The 3x01 variant of the chips always operates in differential mode + # and effectively only has one channel (composed of an IN+ and IN-). As + # such it requires no input, just output. + return self._int_to_words( + (0b10000 | (not self.differential) << 3 | self.channel) << (self.bits + 2) + ) + + +class MCP3xx2(MCP3xxx): + def _send(self): + # MCP3002 protocol looks like the following: + # + # Byte 0 1 + # ==== ======== ======== + # Tx 01MCLxxx xxxxxxxx + # Rx xxxxx0RR RRRRRRRR for the 3002 + # + # MCP3202 protocol looks like the following: + # + # Byte 0 1 2 + # ==== ======== ======== ======== + # Tx 00000001 MCLxxxxx xxxxxxxx + # Rx xxxxxxxx xxx0RRRR RRRRRRRR + # + # The transmit bits start with several preamble "0" bits, the number of + # which is determined by the amount required to align the last byte of + # the result with the final byte of output. A start "1" bit is then + # transmitted, followed by the single/differential bit (M); 1 for + # single-ended read, 0 for differential read. Next comes a single bit + # for channel (M) then the MSBF bit (L) which selects whether the data + # will be read out in MSB form only (1) or whether LSB read-out will + # occur after MSB read-out (0). + # + # Read-out begins with a null bit (0) followed by the result bits (R). + # All other bits are don't care (x). + return self._int_to_words( + (0b1001 | (not self.differential) << 2 | self.channel << 1) << (self.bits + 1) + ) + + +class MCP30xx(MCP3xxx): + """ + Extends :class:`MCP3xxx` to implement an interface for all ADC + chips with a protocol similar to the Microchip MCP30xx series of devices. + """ + + def __init__(self, channel=0, differential=False, max_voltage=3.3, + **spi_args): + super(MCP30xx, self).__init__(channel, 10, differential, max_voltage, + **spi_args) + + +class MCP32xx(MCP3xxx): + """ + Extends :class:`MCP3xxx` to implement an interface for all ADC + chips with a protocol similar to the Microchip MCP32xx series of devices. + """ + + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + super(MCP32xx, self).__init__(channel, 12, differential, max_voltage, + **spi_args) + + +class MCP33xx(MCP3xxx): + """ + Extends :class:`MCP3xxx` with functionality specific to the MCP33xx family + of ADCs; specifically this handles the full differential capability of + these chips supporting the full 13-bit signed range of output values. + """ + + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + super(MCP33xx, self).__init__(channel, 12, differential, max_voltage, + **spi_args) + + def _read(self): + if self.differential: + result = self._words_to_int( + self._spi.transfer(self._send())[-2:], self.bits + 1) + # Account for the sign bit + if result > 4095: + return -(8192 - result) + else: + return result + else: + return super(MCP33xx, self)._read() + + def _send(self): + # MCP3302/04 protocol looks like the following: + # + # Byte 0 1 2 + # ==== ======== ======== ======== + # Tx 00001MCC Cxxxxxxx xxxxxxxx + # Rx xxxxxxxx xx0SRRRR RRRRRRRR + # + # The transmit bits start with 4 preamble bits "0000", a start bit "1" + # followed by the single/differential bit (M) which is 1 for + # single-ended read, and 0 for differential read, followed by 3-bits + # for the channel (C). The remainder of the transmission are "don't + # care" bits (x). + # + # The first byte received and the top 2 bits of the second byte are + # don't care bits (x). These are followed by a null bit (0), then the + # sign bit (S), and then the 12 result bits (R). + # + # In single read mode (the default) the sign bit is always zero and the + # result is effectively 12-bits. In differential mode, the sign bit is + # significant and the result is a two's-complement 13-bit value. + # + # The MCP3301 variant operates similarly to the other MCP3x01 variants; + # no input, just output and always differential. + return self._int_to_words( + (0b10000 | (not self.differential) << 3 | self.channel) << (self.bits + 3) + ) + + @property + def differential(self): + """ + If ``True``, the device is operated in differential mode. In this mode + one channel (specified by the channel attribute) is read relative to + the value of a second channel (implied by the chip's design). + + Please refer to the device data-sheet to determine which channel is + used as the relative base value (for example, when using an + :class:`MCP3304` in differential mode, channel 0 is read relative to + channel 1). + """ + return super(MCP33xx, self).differential + + @property + def value(self): + """ + The current value read from the device, scaled to a value between 0 and + 1 (or -1 to +1 for devices operating in differential mode). + """ + return super(MCP33xx, self).value + + +class MCP3001(MCP30xx): + """ + The `MCP3001`_ is a 10-bit analog to digital converter with 1 channel. + Please note that the MCP3001 always operates in differential mode, + measuring the value of IN+ relative to IN-. + + .. _MCP3001: http://www.farnell.com/datasheets/630400.pdf + """ + def __init__(self, max_voltage=3.3, **spi_args): + super(MCP3001, self).__init__(0, True, max_voltage, **spi_args) + + def _read(self): + # MCP3001 protocol looks like the following: + # + # Byte 0 1 + # ==== ======== ======== + # Rx xx0RRRRR RRRRRxxx + return self._words_to_int(self._spi.read(2), 13) >> 3 + + +class MCP3002(MCP30xx, MCP3xx2): + """ + The `MCP3002`_ is a 10-bit analog to digital converter with 2 channels + (0-1). + + .. _MCP3002: http://www.farnell.com/datasheets/1599363.pdf + """ + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + if not 0 <= channel < 2: + raise SPIBadChannel('channel must be 0 or 1') + super(MCP3002, self).__init__(channel, differential, max_voltage, **spi_args) + + +class MCP3004(MCP30xx): + """ + The `MCP3004`_ is a 10-bit analog to digital converter with 4 channels + (0-3). + + .. _MCP3004: http://www.farnell.com/datasheets/808965.pdf + """ + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + if not 0 <= channel < 4: + raise SPIBadChannel('channel must be between 0 and 3') + super(MCP3004, self).__init__(channel, differential, max_voltage, **spi_args) + + +class MCP3008(MCP30xx): + """ + The `MCP3008`_ is a 10-bit analog to digital converter with 8 channels + (0-7). + + .. _MCP3008: http://www.farnell.com/datasheets/808965.pdf + """ + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + if not 0 <= channel < 8: + raise SPIBadChannel('channel must be between 0 and 7') + super(MCP3008, self).__init__(channel, differential, max_voltage, **spi_args) + + +class MCP3201(MCP32xx): + """ + The `MCP3201`_ is a 12-bit analog to digital converter with 1 channel. + Please note that the MCP3201 always operates in differential mode, + measuring the value of IN+ relative to IN-. + + .. _MCP3201: http://www.farnell.com/datasheets/1669366.pdf + """ + def __init__(self, max_voltage=3.3, **spi_args): + super(MCP3201, self).__init__(0, True, max_voltage, **spi_args) + + def _read(self): + # MCP3201 protocol looks like the following: + # + # Byte 0 1 + # ==== ======== ======== + # Rx xx0RRRRR RRRRRRRx + return self._words_to_int(self._spi.read(2), 13) >> 1 + + +class MCP3202(MCP32xx, MCP3xx2): + """ + The `MCP3202`_ is a 12-bit analog to digital converter with 2 channels + (0-1). + + .. _MCP3202: http://www.farnell.com/datasheets/1669376.pdf + """ + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + if not 0 <= channel < 2: + raise SPIBadChannel('channel must be 0 or 1') + super(MCP3202, self).__init__(channel, differential, max_voltage, **spi_args) + + +class MCP3204(MCP32xx): + """ + The `MCP3204`_ is a 12-bit analog to digital converter with 4 channels + (0-3). + + .. _MCP3204: http://www.farnell.com/datasheets/808967.pdf + """ + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + if not 0 <= channel < 4: + raise SPIBadChannel('channel must be between 0 and 3') + super(MCP3204, self).__init__(channel, differential, max_voltage, **spi_args) + + +class MCP3208(MCP32xx): + """ + The `MCP3208`_ is a 12-bit analog to digital converter with 8 channels + (0-7). + + .. _MCP3208: http://www.farnell.com/datasheets/808967.pdf + """ + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + if not 0 <= channel < 8: + raise SPIBadChannel('channel must be between 0 and 7') + super(MCP3208, self).__init__(channel, differential, max_voltage, **spi_args) + + +class MCP3301(MCP33xx): + """ + The `MCP3301`_ is a signed 13-bit analog to digital converter. Please note + that the MCP3301 always operates in differential mode measuring the + difference between IN+ and IN-. Its output value is scaled from -1 to +1. + + .. _MCP3301: http://www.farnell.com/datasheets/1669397.pdf + """ + def __init__(self, max_voltage=3.3, **spi_args): + super(MCP3301, self).__init__(0, True, max_voltage, **spi_args) + + def _read(self): + # MCP3301 protocol looks like the following: + # + # Byte 0 1 + # ==== ======== ======== + # Rx xx0SRRRR RRRRRRRR + result = self._words_to_int(self._spi.read(2), 13) + # Account for the sign bit + if result > 4095: + return -(8192 - result) + else: + return result + + +class MCP3302(MCP33xx): + """ + The `MCP3302`_ is a 12/13-bit analog to digital converter with 4 channels + (0-3). When operated in differential mode, the device outputs a signed + 13-bit value which is scaled from -1 to +1. When operated in single-ended + mode (the default), the device outputs an unsigned 12-bit value scaled from + 0 to 1. + + .. _MCP3302: http://www.farnell.com/datasheets/1486116.pdf + """ + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + if not 0 <= channel < 4: + raise SPIBadChannel('channel must be between 0 and 4') + super(MCP3302, self).__init__(channel, differential, max_voltage, **spi_args) + + +class MCP3304(MCP33xx): + """ + The `MCP3304`_ is a 12/13-bit analog to digital converter with 8 channels + (0-7). When operated in differential mode, the device outputs a signed + 13-bit value which is scaled from -1 to +1. When operated in single-ended + mode (the default), the device outputs an unsigned 12-bit value scaled from + 0 to 1. + + .. _MCP3304: http://www.farnell.com/datasheets/1486116.pdf + """ + def __init__(self, channel=0, differential=False, max_voltage=3.3, **spi_args): + if not 0 <= channel < 8: + raise SPIBadChannel('channel must be between 0 and 7') + super(MCP3304, self).__init__(channel, differential, max_voltage, **spi_args) + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/threads.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/threads.py new file mode 100644 index 00000000..f54ce4e4 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/threads.py @@ -0,0 +1,39 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, + ) +str = type('') + +from threading import Thread, Event + + +_THREADS = set() +def _threads_shutdown(): + while _THREADS: + for t in _THREADS.copy(): + t.stop() + + +class GPIOThread(Thread): + def __init__(self, group=None, target=None, name=None, args=(), kwargs=None): + if kwargs is None: + kwargs = {} + self.stopping = Event() + super(GPIOThread, self).__init__(group, target, name, args, kwargs) + self.daemon = True + + def start(self): + self.stopping.clear() + _THREADS.add(self) + super(GPIOThread, self).start() + + def stop(self): + self.stopping.set() + self.join() + + def join(self): + super(GPIOThread, self).join() + _THREADS.discard(self) + diff --git a/plugin.program.gpio.monitor/resources/lib/gpiozero/tools.py b/plugin.program.gpio.monitor/resources/lib/gpiozero/tools.py new file mode 100644 index 00000000..63acad44 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/gpiozero/tools.py @@ -0,0 +1,640 @@ +# vim: set fileencoding=utf-8: + +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, +) +str = type('') + + +from random import random +from time import sleep +try: + from itertools import izip as zip +except ImportError: + pass +from itertools import cycle +from math import sin, cos, pi +try: + from statistics import mean +except ImportError: + from .compat import mean +try: + from math import isclose +except ImportError: + from .compat import isclose + + +def negated(values): + """ + Returns the negation of the supplied values (``True`` becomes ``False``, + and ``False`` becomes ``True``). For example:: + + from gpiozero import Button, LED + from gpiozero.tools import negated + from signal import pause + + led = LED(4) + btn = Button(17) + led.source = negated(btn.values) + pause() + """ + for v in values: + yield not v + + +def inverted(values, input_min=0, input_max=1): + """ + Returns the inversion of the supplied values (*input_min* becomes + *input_max*, *input_max* becomes *input_min*, `input_min + 0.1` becomes + `input_max - 0.1`, etc.). All items in *values* are assumed to be between + *input_min* and *input_max* (which default to 0 and 1 respectively), and + the output will be in the same range. For example:: + + from gpiozero import MCP3008, PWMLED + from gpiozero.tools import inverted + from signal import pause + + led = PWMLED(4) + pot = MCP3008(channel=0) + led.source = inverted(pot.values) + pause() + """ + if input_min >= input_max: + raise ValueError('input_min must be smaller than input_max') + for v in values: + yield input_min + input_max - v + + +def scaled(values, output_min, output_max, input_min=0, input_max=1): + """ + Returns *values* scaled from *output_min* to *output_max*, assuming that + all items in *values* lie between *input_min* and *input_max* (which + default to 0 and 1 respectively). For example, to control the direction of + a motor (which is represented as a value between -1 and 1) using a + potentiometer (which typically provides values between 0 and 1):: + + from gpiozero import Motor, MCP3008 + from gpiozero.tools import scaled + from signal import pause + + motor = Motor(20, 21) + pot = MCP3008(channel=0) + motor.source = scaled(pot.values, -1, 1) + pause() + + .. warning:: + + If *values* contains elements that lie outside *input_min* to + *input_max* (inclusive) then the function will not produce values that + lie within *output_min* to *output_max* (inclusive). + """ + if input_min >= input_max: + raise ValueError('input_min must be smaller than input_max') + input_size = input_max - input_min + output_size = output_max - output_min + for v in values: + yield (((v - input_min) / input_size) * output_size) + output_min + + +def clamped(values, output_min=0, output_max=1): + """ + Returns *values* clamped from *output_min* to *output_max*, i.e. any items + less than *output_min* will be returned as *output_min* and any items + larger than *output_max* will be returned as *output_max* (these default to + 0 and 1 respectively). For example:: + + from gpiozero import PWMLED, MCP3008 + from gpiozero.tools import clamped + from signal import pause + + led = PWMLED(4) + pot = MCP3008(channel=0) + + led.source = clamped(pot.values, 0.5, 1.0) + + pause() + """ + if output_min >= output_max: + raise ValueError('output_min must be smaller than output_max') + for v in values: + yield min(max(v, output_min), output_max) + + +def absoluted(values): + """ + Returns *values* with all negative elements negated (so that they're + positive). For example:: + + from gpiozero import PWMLED, Motor, MCP3008 + from gpiozero.tools import absoluted, scaled + from signal import pause + + led = PWMLED(4) + motor = Motor(22, 27) + pot = MCP3008(channel=0) + + motor.source = scaled(pot.values, -1, 1) + led.source = absoluted(motor.values) + + pause() + """ + for v in values: + yield abs(v) + + +def quantized(values, steps, input_min=0, input_max=1): + """ + Returns *values* quantized to *steps* increments. All items in *values* are + assumed to be between *input_min* and *input_max* (which default to 0 and + 1 respectively), and the output will be in the same range. + + For example, to quantize values between 0 and 1 to 5 "steps" (0.0, 0.25, + 0.5, 0.75, 1.0):: + + from gpiozero import PWMLED, MCP3008 + from gpiozero.tools import quantized + from signal import pause + + led = PWMLED(4) + pot = MCP3008(channel=0) + led.source = quantized(pot.values, 4) + pause() + """ + if steps < 1: + raise ValueError("steps must be 1 or larger") + if input_min >= input_max: + raise ValueError('input_min must be smaller than input_max') + input_size = input_max - input_min + for v in scaled(values, 0, 1, input_min, input_max): + yield ((int(v * steps) / steps) * input_size) + input_min + + +def booleanized(values, min_value, max_value, hysteresis=0): + """ + Returns True for each item in *values* between *min_value* and + *max_value*, and False otherwise. *hysteresis* can optionally be used to + add `hysteresis`_ which prevents the output value rapidly flipping when + the input value is fluctuating near the *min_value* or *max_value* + thresholds. For example, to light an LED only when a potentiometer is + between 1/4 and 3/4 of its full range:: + + from gpiozero import LED, MCP3008 + from gpiozero.tools import booleanized + from signal import pause + + led = LED(4) + pot = MCP3008(channel=0) + led.source = booleanized(pot.values, 0.25, 0.75) + pause() + + .. _hysteresis: https://en.wikipedia.org/wiki/Hysteresis + """ + if min_value >= max_value: + raise ValueError('min_value must be smaller than max_value') + min_value = float(min_value) + max_value = float(max_value) + if hysteresis < 0: + raise ValueError("hysteresis must be 0 or larger") + else: + hysteresis = float(hysteresis) + if (max_value - min_value) <= hysteresis: + raise ValueError('The gap between min_value and max_value must be larger than hysteresis') + last_state = None + for v in values: + if v < min_value: + new_state = 'below' + elif v > max_value: + new_state = 'above' + else: + new_state = 'in' + switch = False + if last_state == None or not hysteresis: + switch = True + elif new_state == last_state: + pass + else: # new_state != last_state + if last_state == 'below' and new_state == 'in': + switch = v >= min_value + hysteresis + elif last_state == 'in' and new_state == 'below': + switch = v < min_value - hysteresis + elif last_state == 'in' and new_state == 'above': + switch = v > max_value + hysteresis + elif last_state == 'above' and new_state == 'in': + switch = v <= max_value - hysteresis + else: # above->below or below->above + switch = True + if switch: + last_state = new_state + yield last_state == 'in' + + +def all_values(*values): + """ + Returns the `logical conjunction`_ of all supplied values (the result is + only ``True`` if and only if all input values are simultaneously ``True``). + One or more *values* can be specified. For example, to light an + :class:`LED` only when *both* buttons are pressed:: + + from gpiozero import LED, Button + from gpiozero.tools import all_values + from signal import pause + + led = LED(4) + btn1 = Button(20) + btn2 = Button(21) + led.source = all_values(btn1.values, btn2.values) + pause() + + .. _logical conjunction: https://en.wikipedia.org/wiki/Logical_conjunction + """ + for v in zip(*values): + yield all(v) + + +def any_values(*values): + """ + Returns the `logical disjunction`_ of all supplied values (the result is + ``True`` if any of the input values are currently ``True``). One or more + *values* can be specified. For example, to light an :class:`LED` when + *any* button is pressed:: + + from gpiozero import LED, Button + from gpiozero.tools import any_values + from signal import pause + + led = LED(4) + btn1 = Button(20) + btn2 = Button(21) + led.source = any_values(btn1.values, btn2.values) + pause() + + .. _logical disjunction: https://en.wikipedia.org/wiki/Logical_disjunction + """ + for v in zip(*values): + yield any(v) + + +def averaged(*values): + """ + Returns the mean of all supplied values. One or more *values* can be + specified. For example, to light a :class:`PWMLED` as the average of + several potentiometers connected to an :class:`MCP3008` ADC:: + + from gpiozero import MCP3008, PWMLED + from gpiozero.tools import averaged + from signal import pause + + pot1 = MCP3008(channel=0) + pot2 = MCP3008(channel=1) + pot3 = MCP3008(channel=2) + led = PWMLED(4) + + led.source = averaged(pot1.values, pot2.values, pot3.values) + + pause() + """ + for v in zip(*values): + yield mean(v) + + +def summed(*values): + """ + Returns the sum of all supplied values. One or more *values* can be + specified. For example, to light a :class:`PWMLED` as the (scaled) sum of + several potentiometers connected to an :class:`MCP3008` ADC:: + + from gpiozero import MCP3008, PWMLED + from gpiozero.tools import summed, scaled + from signal import pause + + pot1 = MCP3008(channel=0) + pot2 = MCP3008(channel=1) + pot3 = MCP3008(channel=2) + led = PWMLED(4) + + led.source = scaled(summed(pot1.values, pot2.values, pot3.values), 0, 1, 0, 3) + + pause() + """ + for v in zip(*values): + yield sum(v) + + +def multiplied(*values): + """ + Returns the product of all supplied values. One or more *values* can be + specified. For example, to light a :class:`PWMLED` as the product (i.e. + multiplication) of several potentiometers connected to an :class:`MCP3008` + ADC:: + + from gpiozero import MCP3008, PWMLED + from gpiozero.tools import multiplied + from signal import pause + + pot1 = MCP3008(channel=0) + pot2 = MCP3008(channel=1) + pot3 = MCP3008(channel=2) + led = PWMLED(4) + + led.source = multiplied(pot1.values, pot2.values, pot3.values) + + pause() + """ + def _product(it): + p = 1 + for n in it: + p *= n + return p + for v in zip(*values): + yield _product(v) + + +def queued(values, qsize): + """ + Queues up readings from *values* (the number of readings queued is + determined by *qsize*) and begins yielding values only when the queue is + full. For example, to "cascade" values along a sequence of LEDs:: + + from gpiozero import LEDBoard, Button + from gpiozero.tools import queued + from signal import pause + + leds = LEDBoard(5, 6, 13, 19, 26) + btn = Button(17) + + for i in range(4): + leds[i].source = queued(leds[i + 1].values, 5) + leds[i].source_delay = 0.01 + + leds[4].source = btn.values + + pause() + """ + if qsize < 1: + raise ValueError("qsize must be 1 or larger") + q = [] + it = iter(values) + for i in range(qsize): + q.append(next(it)) + for i in cycle(range(qsize)): + yield q[i] + try: + q[i] = next(it) + except StopIteration: + break + + +def smoothed(values, qsize, average=mean): + """ + Queues up readings from *values* (the number of readings queued is + determined by *qsize*) and begins yielding the *average* of the last + *qsize* values when the queue is full. The larger the *qsize*, the more the + values are smoothed. For example, to smooth the analog values read from an + ADC:: + + from gpiozero import MCP3008 + from gpiozero.tools import smoothed + + adc = MCP3008(channel=0) + + for value in smoothed(adc.values, 5): + print(value) + """ + if qsize < 1: + raise ValueError("qsize must be 1 or larger") + q = [] + it = iter(values) + for i in range(qsize): + q.append(next(it)) + for i in cycle(range(qsize)): + yield average(q) + try: + q[i] = next(it) + except StopIteration: + break + + +def pre_delayed(values, delay): + """ + Waits for *delay* seconds before returning each item from *values*. + """ + if delay < 0: + raise ValueError("delay must be 0 or larger") + for v in values: + sleep(delay) + yield v + + +def post_delayed(values, delay): + """ + Waits for *delay* seconds after returning each item from *values*. + """ + if delay < 0: + raise ValueError("delay must be 0 or larger") + for v in values: + yield v + sleep(delay) + + +def pre_periodic_filtered(values, block, repeat_after): + """ + Blocks the first *block* items from *values*, repeating the block after + every *repeat_after* items, if *repeat_after* is non-zero. For example, to + discard the first 50 values read from an ADC:: + + from gpiozero import MCP3008 + from gpiozero.tools import pre_periodic_filtered + + adc = MCP3008(channel=0) + + for value in pre_periodic_filtered(adc.values, 50, 0): + print(value) + + Or to only display every even item read from an ADC:: + + from gpiozero import MCP3008 + from gpiozero.tools import pre_periodic_filtered + + adc = MCP3008(channel=0) + + for value in pre_periodic_filtered(adc.values, 1, 1): + print(value) + """ + if block < 1: + raise ValueError("block must be 1 or larger") + if repeat_after < 0: + raise ValueError("repeat_after must be 0 or larger") + it = iter(values) + if repeat_after == 0: + for _ in range(block): + next(it) + while True: + yield next(it) + else: + while True: + for _ in range(block): + next(it) + for _ in range(repeat_after): + yield next(it) + + +def post_periodic_filtered(values, repeat_after, block): + """ + After every *repeat_after* items, blocks the next *block* items from + *values*. Note that unlike :func:`pre_periodic_filtered`, *repeat_after* + can't be 0. For example, to block every tenth item read from an ADC:: + + from gpiozero import MCP3008 + from gpiozero.tools import post_periodic_filtered + + adc = MCP3008(channel=0) + + for value in post_periodic_filtered(adc.values, 9, 1): + print(value) + """ + if repeat_after < 1: + raise ValueError("repeat_after must be 1 or larger") + if block < 1: + raise ValueError("block must be 1 or larger") + it = iter(values) + while True: + for _ in range(repeat_after): + yield next(it) + for _ in range(block): + next(it) + + +def random_values(): + """ + Provides an infinite source of random values between 0 and 1. For example, + to produce a "flickering candle" effect with an LED:: + + from gpiozero import PWMLED + from gpiozero.tools import random_values + from signal import pause + + led = PWMLED(4) + + led.source = random_values() + + pause() + + If you require a wider range than 0 to 1, see :func:`scaled`. + """ + while True: + yield random() + + +def sin_values(period=360): + """ + Provides an infinite source of values representing a sine wave (from -1 to + +1) which repeats every *period* values. For example, to produce a "siren" + effect with a couple of LEDs that repeats once a second:: + + from gpiozero import PWMLED + from gpiozero.tools import sin_values, scaled, inverted + from signal import pause + + red = PWMLED(2) + blue = PWMLED(3) + + red.source_delay = 0.01 + blue.source_delay = red.source_delay + red.source = scaled(sin_values(100), 0, 1, -1, 1) + blue.source = inverted(red.values) + + pause() + + If you require a different range than -1 to +1, see :func:`scaled`. + """ + angles = (2 * pi * i / period for i in range(period)) + for a in cycle(angles): + yield sin(a) + + +def cos_values(period=360): + """ + Provides an infinite source of values representing a cosine wave (from -1 + to +1) which repeats every *period* values. For example, to produce a + "siren" effect with a couple of LEDs that repeats once a second:: + + from gpiozero import PWMLED + from gpiozero.tools import cos_values, scaled, inverted + from signal import pause + + red = PWMLED(2) + blue = PWMLED(3) + + red.source_delay = 0.01 + blue.source_delay = red.source_delay + red.source = scaled(cos_values(100), 0, 1, -1, 1) + blue.source = inverted(red.values) + + pause() + + If you require a different range than -1 to +1, see :func:`scaled`. + """ + angles = (2 * pi * i / period for i in range(period)) + for a in cycle(angles): + yield cos(a) + + +def alternating_values(initial_value=False): + """ + Provides an infinite source of values alternating between ``True`` and + ``False``, starting wth *initial_value* (which defaults to ``False``). For + example, to produce a flashing LED:: + + from gpiozero import LED + from gpiozero.tools import alternating_values + from signal import pause + + red = LED(2) + + red.source_delay = 0.5 + red.source = alternating_values() + + pause() + """ + value = initial_value + while True: + yield value + value = not value + + +def ramping_values(period=360): + """ + Provides an infinite source of values representing a triangle wave (from 0 + to 1 and back again) which repeats every *period* values. For example, to + pulse an LED once a second:: + + from gpiozero import PWMLED + from gpiozero.tools import ramping_values + from signal import pause + + red = PWMLED(2) + + red.source_delay = 0.01 + red.source = ramping_values(100) + + pause() + + If you require a wider range than 0 to 1, see :func:`scaled`. + """ + step = 2 / period + value = 0 + while True: + yield value + value += step + if isclose(value, 1, abs_tol=1e-9): + value = 1 + step *= -1 + elif isclose(value, 0, abs_tol=1e-9): + value = 0 + step *= -1 + elif value > 1 or value < 0: + step *= -1 + value += step diff --git a/plugin.program.gpio.monitor/resources/lib/language.py b/plugin.program.gpio.monitor/resources/lib/language.py new file mode 100644 index 00000000..365d71eb --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/language.py @@ -0,0 +1,59 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ADD_BTN = 30000 + RELOAD_SERVICE = 30001 + GPIOZERO_ERROR = 30002 + PIN_LABEL = 30003 + INSTALL_SERVICE = 30004 + RELOAD_SERVICE_DESC = 30005 + INSTALL_SERVICE_DESC = 30006 + DELETE_BTN = 30007 + DISABLE_OTHER_BTN = 30008 + CONFIRM_DELETE_BTN = 30009 + AUTO_RELOAD_SERVICE = 30010 + BTN_LABEL = 30011 + SERVICE_RELOADED = 30012 + BTN_PIN = 30013 + BTN_NAME = 30014 + BTN_ENABLED = 30015 + BTN_PULLUP = 30016 + BTN_BOUNCE_TIME = 30017 + BTN_HOLD_TIME = 30018 + BTN_HOLD_REPEAT = 30019 + BTN_WHEN_PRESSED = 30020 + BTN_WHEN_RELEASED = 30021 + BTN_WHEN_HELD = 30022 + BTN_OPTION = 30023 + BTN_PIN_DESC = 30024 + BTN_NAME_DESC = 30025 + BTN_ENABLED_DESC = 30026 + BTN_PULLUP_DESC = 30027 + BTN_BOUNCE_TIME_DESC = 30028 + BTN_HOLD_TIME_DESC = 30029 + BTN_HOLD_REPEAT_DESC = 30030 + BTN_WHEN_PRESSED_DESC = 30031 + BTN_WHEN_RELEASED_DESC = 30032 + BTN_WHEN_HELD_DESC = 30033 + ADD_BTN_DESC = 30034 + SYSTEM_UNSUPPORTED = 30035 + SERVICE_NOT_INSTALLED = 30036 + STATUS_INACTIVE = 30037 + STATUS_ACTIVE = 30038 + STATUS_ERROR = 30039 + STATUS_DISABLED = 30040 + BTN_STATUS = 30041 + BTN_ERROR = 30042 + RESTART_REQUIRED = 30043 + XBIAN_PASSWORD = 30044 + XBIAN_ERROR = 30045 + STATUS_ACTIVE_DESC = 30046 + STATUS_INACTIVE_DESC = 30047 + STATUS_DISABLED_DESC = 30048 + STATUS_ERROR_DESC = 30049 + TEST_PRESS = 30050 + TEST_RELEASE = 30051 + TEST_HOLD = 30052 + SIMULATION = 30053 + +_ = Language() \ No newline at end of file diff --git a/plugin.program.gpio.monitor/resources/lib/models.py b/plugin.program.gpio.monitor/resources/lib/models.py new file mode 100644 index 00000000..b8499123 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/models.py @@ -0,0 +1,141 @@ +import peewee + +from slyguy import database, gui + +from .constants import BCM_PINS, COLOR_INACTIVE, COLOR_ACTIVE, COLOR_DISABLED, COLOR_ERROR +from .language import _ + +class Button(database.Model): + class Status(object): + INACTIVE = 1 + ACTIVE = 2 + ERROR = 3 + DISABLED = 4 + + pin = peewee.IntegerField() + name = peewee.TextField(null=True) + enabled = peewee.BooleanField(default=False) + + pull_up = peewee.BooleanField(default=True) + bounce_time = peewee.FloatField(null=True) + hold_time = peewee.FloatField(default=1) + hold_repeat = peewee.BooleanField(default=False) + + when_pressed = peewee.TextField(null=True) + when_released = peewee.TextField(null=True) + when_held = peewee.TextField(null=True) + + status = peewee.IntegerField(default=Status.INACTIVE) + error = peewee.TextField(null=True) + + def label(self): + status, description = self.status_label() + + return _(_.BTN_LABEL, + name = self.name or '', + pin = self.pin_label, + status = status, + ), description + + @property + def pin_label(self): + return _(_.PIN_LABEL, pin=self.pin) + + def status_label(self): + if self.status == Button.Status.DISABLED: + return _(_.STATUS_DISABLED, _color=COLOR_DISABLED), _.STATUS_DISABLED_DESC + elif self.status == Button.Status.ERROR: + return _(_.STATUS_ERROR, _color=COLOR_ERROR), _(_.STATUS_ERROR_DESC, error=_(self.error, _color=COLOR_ERROR)) + elif self.status == Button.Status.ACTIVE: + return _(_.STATUS_ACTIVE, _color=COLOR_ACTIVE), _.STATUS_ACTIVE_DESC + else: + return _(_.STATUS_INACTIVE, _color=COLOR_INACTIVE), _.STATUS_INACTIVE_DESC + + def has_callbacks(self): + return self.when_pressed or self.when_released or self.when_held + + def select_pin(self): + options = [_(_.PIN_LABEL, pin=x) for x in BCM_PINS] + index = gui.select(_.BTN_PIN, options) + if index < 0: + return False + + self.pin = BCM_PINS[index] + if self.enabled: + self.enabled = False + self.toggle_enabled() + + return True + + def select_name(self): + name = gui.input(_.BTN_NAME, default=self.name or '') + if not name: + return False + + self.name = name + return True + + def toggle_enabled(self): + if self.enabled: + self.enabled = False + else: + pin_used = Button.select(Button.id).where(Button.id != self.id, Button.pin == self.pin, Button.enabled == True).exists() + if pin_used: + if not gui.yes_no(_.DISABLE_OTHER_BTN): + return False + + Button.update(enabled=False).where(Button.pin == self.pin).execute() + + self.enabled = True + + return True + + def toggle_pull_up(self): + self.pull_up = not self.pull_up + return True + + def select_bounce_time(self): + bounce_time = gui.input(_.BTN_BOUNCE_TIME, default=str(self.bounce_time) if self.bounce_time else '') + if not bounce_time: + return False + + self.bounce_time = float(bounce_time) + return True + + def select_hold_time(self): + hold_time = gui.input(_.BTN_HOLD_TIME, default=str(self.hold_time)) + if not hold_time: + return False + + self.hold_time = float(hold_time) + return True + + def toggle_hold_repeat(self): + self.hold_repeat = not self.hold_repeat + return True + + def select_when_pressed(self): + when_pressed = gui.input(_.BTN_WHEN_PRESSED, default=self.when_pressed or '') + if not when_pressed: + return False + + self.when_pressed = when_pressed + return True + + def select_when_released(self): + when_released = gui.input(_.BTN_WHEN_RELEASED, default=self.when_released or '') + if not when_released: + return False + + self.when_released = when_released + return True + + def select_when_held(self): + when_held = gui.input(_.BTN_WHEN_HELD, default=self.when_held or '') + if not when_held: + return False + + self.when_held = when_held + return True + +database.tables.append(Button) \ No newline at end of file diff --git a/plugin.program.gpio.monitor/resources/lib/plugin.py b/plugin.program.gpio.monitor/resources/lib/plugin.py new file mode 100644 index 00000000..2c494620 --- /dev/null +++ b/plugin.program.gpio.monitor/resources/lib/plugin.py @@ -0,0 +1,220 @@ +from kodi_six import xbmc + +from slyguy import plugin, gui, settings, database, signals +from slyguy.constants import ADDON_ID +from slyguy.util import set_kodi_string, get_kodi_string + +from . import gpio +from .language import _ +from .models import Button +from .constants import FUNCTION_DELIMETER, AUTO_RELOAD_SETTING + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not gpio.INSTALLED: + folder.add_item( + label = _(_.INSTALL_SERVICE, _bold=True), + path = plugin.url_for(install_service), + info = {'plot': _.INSTALL_SERVICE_DESC}, + bookmark = False, + ) + + if gpio.SYSTEM == 'mock': + if settings.is_fresh(): + gui.ok(_.SYSTEM_UNSUPPORTED) + + folder.title = _(_.SIMULATION, _color='red') + + btns = list(Button.select()) + + for btn in btns: + label, description = btn.label() + + item = plugin.Item( + label = label, + info = {'plot': description}, + path = plugin.url_for(view_btn, id=btn.id), + ) + + item.context.append((_.DELETE_BTN, 'RunPlugin({})'.format(plugin.url_for(delete_btn, id=btn.id)))) + + if btn.when_pressed: + item.context.append((_.TEST_PRESS, 'RunPlugin({})'.format(plugin.url_for(test_btn, id=btn.id, method='when_pressed')))) + + if btn.when_released: + item.context.append((_.TEST_RELEASE, 'RunPlugin({})'.format(plugin.url_for(test_btn, id=btn.id, method='when_released')))) + + if btn.when_held: + item.context.append((_.TEST_HOLD, 'RunPlugin({})'.format(plugin.url_for(test_btn, id=btn.id, method='when_held')))) + + folder.add_items([item]) + + folder.add_item( + label = _(_.ADD_BTN, _bold=True), + path = plugin.url_for(add_btn), + info = {'plot': _.ADD_BTN_DESC}, + ) + + if not settings.getBool(AUTO_RELOAD_SETTING, False): + folder.add_item( + label = _.RELOAD_SERVICE, + path = plugin.url_for(reload_service), + info = {'plot': _.RELOAD_SERVICE_DESC}, + ) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def add_btn(**kwargs): + btn = Button(enabled=True) + if not btn.select_pin(): + return + + btn.save() + _on_btn_change() + +@plugin.route() +def test_btn(id, method, **kwargs): + btn = Button.get_by_id(id) + gpio.callback(getattr(btn, method)) + +@plugin.route() +def view_btn(id, **kwargs): + btn = Button.get_by_id(id) + + folder = plugin.Folder(title=btn.pin_label, cacheToDisc=False) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_PIN, value=btn.pin_label), + path = plugin.url_for(edit_btn, id=btn.id, method='select_pin'), + info = {'plot': _.BTN_PIN_DESC}, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_WHEN_PRESSED, value=btn.when_pressed), + path = plugin.url_for(edit_btn, id=btn.id, method='select_when_pressed'), + info = {'plot': _(_.BTN_WHEN_PRESSED_DESC, delimiter=FUNCTION_DELIMETER)}, + context = [(_.TEST_PRESS, 'RunPlugin({})'.format(plugin.url_for(test_btn, id=btn.id, method='when_pressed')))] if btn.when_pressed else None, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_WHEN_RELEASED, value=btn.when_released), + path = plugin.url_for(edit_btn, id=btn.id, method='select_when_released'), + info = {'plot': _(_.BTN_WHEN_RELEASED_DESC, delimiter=FUNCTION_DELIMETER)}, + context = [(_.TEST_RELEASE, 'RunPlugin({})'.format(plugin.url_for(test_btn, id=btn.id, method='when_released')))] if btn.when_released else None, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_WHEN_HELD, value=btn.when_held), + path = plugin.url_for(edit_btn, id=btn.id, method='select_when_held'), + info = {'plot': _(_.BTN_WHEN_HELD_DESC, delimiter=FUNCTION_DELIMETER)}, + context = [(_.TEST_HOLD, 'RunPlugin({})'.format(plugin.url_for(test_btn, id=btn.id, method='when_held')))] if btn.when_held else None, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_NAME, value=btn.name), + path = plugin.url_for(edit_btn, id=btn.id, method='select_name'), + info = {'plot': _.BTN_NAME_DESC}, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_ENABLED, value=btn.enabled), + path = plugin.url_for(edit_btn, id=btn.id, method='toggle_enabled'), + info = {'plot': _.BTN_ENABLED_DESC}, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_PULLUP, value=btn.pull_up), + path = plugin.url_for(edit_btn, id=btn.id, method='toggle_pull_up'), + info = {'plot': _.BTN_PULLUP_DESC}, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_BOUNCE_TIME, value=btn.bounce_time), + path = plugin.url_for(edit_btn, id=btn.id, method='select_bounce_time'), + info = {'plot': _.BTN_BOUNCE_TIME_DESC}, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_HOLD_TIME, value=btn.hold_time), + path = plugin.url_for(edit_btn, id=btn.id, method='select_hold_time'), + info = {'plot': _.BTN_HOLD_TIME_DESC}, + ) + + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_HOLD_REPEAT, value=btn.hold_repeat), + path = plugin.url_for(edit_btn, id=btn.id, method='toggle_hold_repeat'), + info = {'plot': _.BTN_HOLD_REPEAT_DESC}, + ) + + if not settings.getBool(AUTO_RELOAD_SETTING, False): + folder.add_item( + label = _.RELOAD_SERVICE, + path = plugin.url_for(reload_service), + info = {'plot': _.RELOAD_SERVICE_DESC}, + ) + + label, desc = btn.status_label() + folder.add_item( + label = _(_.BTN_OPTION, option=_.BTN_STATUS, value=label,), + is_folder = False, + info = {'plot': desc}, + ) + + return folder + +@plugin.route() +def edit_btn(id, method, **kwargs): + btn = Button.get_by_id(id) + if getattr(btn, method)(): + btn.save() + _on_btn_change() + +@plugin.route() +def delete_btn(id, **kwargs): + btn = Button.get_by_id(id) + if gui.yes_no(_.CONFIRM_DELETE_BTN) and btn.delete_instance(): + _on_btn_change() + +@plugin.route() +def reload_service(**kwargs): + _reload_service() + +@plugin.route() +def install_service(**kwargs): + with gui.progress(_.INSTALL_SERVICE, percent=100) as progress: + restart_required = gpio.install() + + if restart_required and gui.yes_no(_.RESTART_REQUIRED): + plugin.reboot() + + gui.refresh() + +@plugin.route() +def set_state(pin, state, **kwargs): + gpio.set_state(pin, state) + +def _on_btn_change(): + if settings.getBool(AUTO_RELOAD_SETTING, False): + _reload_service() + + gui.refresh() + +@signals.on(signals.AFTER_RESET) +def _reload_service(): + database.close() + + with gui.progress(_.RELOAD_SERVICE, percent=100) as progress: + set_kodi_string('_gpio_reload', '1') + + for i in range(5): + xbmc.sleep(1000) + if not get_kodi_string('_gpio_reload'): + break \ No newline at end of file diff --git a/plugin.program.gpio.monitor/resources/settings.xml b/plugin.program.gpio.monitor/resources/settings.xml new file mode 100644 index 00000000..9d61e7af --- /dev/null +++ b/plugin.program.gpio.monitor/resources/settings.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.program.gpio.monitor/service.py b/plugin.program.gpio.monitor/service.py new file mode 100644 index 00000000..3b2d39c6 --- /dev/null +++ b/plugin.program.gpio.monitor/service.py @@ -0,0 +1,3 @@ +from resources.lib import gpio + +gpio.service() \ No newline at end of file diff --git a/plugin.program.iptv.merge/__init__.py b/plugin.program.iptv.merge/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.program.iptv.merge/addon.xml b/plugin.program.iptv.merge/addon.xml new file mode 100644 index 00000000..0870590a --- /dev/null +++ b/plugin.program.iptv.merge/addon.xml @@ -0,0 +1,22 @@ + + + + + + + executable + + + + Easily merge multiple IPTV playlists & EPGs from remote, local or supported add-on sources. + true + + + + + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.program.iptv.merge/default.py b/plugin.program.iptv.merge/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.program.iptv.merge/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.program.iptv.merge/fanart.jpg b/plugin.program.iptv.merge/fanart.jpg new file mode 100644 index 00000000..b8a6f115 Binary files /dev/null and b/plugin.program.iptv.merge/fanart.jpg differ diff --git a/plugin.program.iptv.merge/icon.png b/plugin.program.iptv.merge/icon.png new file mode 100644 index 00000000..d8f4c212 Binary files /dev/null and b/plugin.program.iptv.merge/icon.png differ diff --git a/plugin.program.iptv.merge/resources/__init__.py b/plugin.program.iptv.merge/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.program.iptv.merge/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.program.iptv.merge/resources/language/resource.language.en_gb/strings.po b/plugin.program.iptv.merge/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..29fdc4c8 --- /dev/null +++ b/plugin.program.iptv.merge/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,352 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Output Directory" +msgstr "" + +msgctxt "#30002" +msgid "Auto Merge Sources" +msgstr "" + +msgctxt "#30003" +msgid "Hours between merges" +msgstr "" + +msgctxt "#30004" +msgid "Reload IPTV Simple Client after merge" +msgstr "" + +msgctxt "#30005" +msgid "Channels per page" +msgstr "" + +msgctxt "#30006" +msgid "Setup IPTV Simple Client" +msgstr "" + +msgctxt "#30007" +msgid "IPTV Simple Client Setup Complete\n" +"You can now add Playlists and then run a merge" +msgstr "" + +msgctxt "#30008" +msgid "Setting up IPTV Simple Client..." +msgstr "" + +msgctxt "#30009" +msgid "Playlists" +msgstr "" + +msgctxt "#30010" +msgid "Add Playlist" +msgstr "" + +msgctxt "#30011" +msgid "Delete Playlist" +msgstr "" + +msgctxt "#30012" +msgid "That source already exists" +msgstr "" + +msgctxt "#30013" +msgid "URL" +msgstr "" + +msgctxt "#30014" +msgid "File" +msgstr "" + +msgctxt "#30015" +msgid "Add-on" +msgstr "" + +msgctxt "#30016" +msgid "Select source type" +msgstr "" + +msgctxt "#30017" +msgid "Enter source URL" +msgstr "" + +msgctxt "#30018" +msgid "Select source file" +msgstr "" + +msgctxt "#30019" +msgid "No compatible add-ons found\n" +"Make sure the add-on is installed and enabled" +msgstr "" + +msgctxt "#30020" +msgid "Select source add-on" +msgstr "" + +msgctxt "#30021" +msgid "Add EPG" +msgstr "" + +msgctxt "#30022" +msgid "Edit Playlist" +msgstr "" + +msgctxt "#30023" +msgid "Source: {value}" +msgstr "" + +msgctxt "#30024" +msgid "Archive Type: {value}" +msgstr "" + +msgctxt "#30025" +msgid "None" +msgstr "" + +msgctxt "#30026" +msgid "gzip" +msgstr "" + +msgctxt "#30027" +msgid "Enabled: {value}" +msgstr "" + +msgctxt "#30028" +msgid "Ignore Playlist Channel Numbers: {value}" +msgstr "" + +msgctxt "#30029" +msgid "Starting Channel Number: {value}" +msgstr "" + +msgctxt "#30030" +msgid "Default Visible Channels: {value}" +msgstr "" + +msgctxt "#30031" +msgid "Group Name: {value}" +msgstr "" + +msgctxt "#30032" +msgid "Ignore Playlist Groups: {value}" +msgstr "" + +msgctxt "#30033" +msgid "Group Name: {value}" +msgstr "" + +msgctxt "#30034" +msgid "Enter Group Name" +msgstr "" + +msgctxt "#30035" +msgid "Enter Starting Channel Number" +msgstr "" + +msgctxt "#30036" +msgid "Select Archive Type" +msgstr "" + +msgctxt "#30037" +msgid "Manage TV" +msgstr "" + +msgctxt "#30038" +msgid "All Channels" +msgstr "" + +msgctxt "#30039" +msgid "Delete EPG" +msgstr "" + +msgctxt "#30040" +msgid "Edit EPG" +msgstr "" + +msgctxt "#30041" +msgid "EPG Source: {value}" +msgstr "" + +msgctxt "#30042" +msgid "{label} (DISABLED)" +msgstr "" + +msgctxt "#30043" +msgid "Merge Complete" +msgstr "" + +msgctxt "#30044" +msgid "Run Merge" +msgstr "" + +msgctxt "#30045" +msgid "Merge Started" +msgstr "" + +msgctxt "#30046" +msgid "A merge is already in progress" +msgstr "" + +msgctxt "#30047" +msgid "Enter Channel URL" +msgstr "" + +msgctxt "#30048" +msgid "Next ({page})" +msgstr "" + +msgctxt "#30049" +msgid "{chno} {name}" +msgstr "" + +msgctxt "#30050" +msgid "{label} [COLOR red](HIDDEN)[/COLOR]" +msgstr "" + +msgctxt "#30051" +msgid "(NO NAME)" +msgstr "" + +msgctxt "#30052" +msgid "Show Channel" +msgstr "" + +msgctxt "#30053" +msgid "Hide Channel" +msgstr "" + +msgctxt "#30054" +msgid "Play Channel" +msgstr "" + +msgctxt "#30055" +msgid "Delete Channel" +msgstr "" + +msgctxt "#30056" +msgid "Reset Channel" +msgstr "" + +msgctxt "#30057" +msgid "Edit Channel" +msgstr "" + +msgctxt "#30058" +msgid "[COLOR blue]{label}[/COLOR]" +msgstr "" + +msgctxt "#30059" +msgid "Playlist" +msgstr "" + +msgctxt "#30060" +msgid "Add Channel" +msgstr "" + +msgctxt "#30061" +msgid "EPGs" +msgstr "" + +msgctxt "#30062" +msgid "Add-on source timed out\n" +"{url}" +msgstr "" + +msgctxt "#30063" +msgid "Add-on source failed\n" +"{url}" +msgstr "" + +msgctxt "#30064" +msgid "Local path not found\n" +"{path}" +msgstr "" + +msgctxt "#30065" +msgid "ID" +msgstr "" + +msgctxt "#30066" +msgid "Use IPTV Merge Proxy for HTTP streams" +msgstr "" + +msgctxt "#30067" +msgid "Add-on Settings" +msgstr "" + +msgctxt "#30068" +msgid "[B][COLOR orange]Pending[/COLOR][/B]\n\n" +"'Run Merge' in main menu\n" +"to force a merge" +msgstr "" + +msgctxt "#30069" +msgid "[B][COLOR gray]Disabled[/COLOR][/B]\n\n" +"Will not be included in merges" +msgstr "" + +msgctxt "#30070" +msgid "Starting Channel Number" +msgstr "" + +msgctxt "#30071" +msgid "Manage Radio" +msgstr "" + +msgctxt "#30072" +msgid "{group} (RADIO)" +msgstr "" + +msgctxt "#30073" +msgid "Run merge when service starts" +msgstr "" + +msgctxt "#30074" +msgid "xz" +msgstr "" + +msgctxt "#30075" +msgid "Clean Kodi TV/EPG DB after merge" +msgstr "" + +msgctxt "#30076" +msgid "Insert Playlist" +msgstr "" + +msgctxt "#30077" +msgid "Ask to add Playlist/EPG" +msgstr "" + +msgctxt "#30078" +msgid "Group Order Override" +msgstr "" + +msgctxt "#30079" +msgid "Free-IPTV lists are not allowed due to being private pay-to-use repo. Try [B]github.com/iptv-org/iptv[/B] instead!" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.program.iptv.merge/resources/lib/__init__.py b/plugin.program.iptv.merge/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.program.iptv.merge/resources/lib/constants.py b/plugin.program.iptv.merge/resources/lib/constants.py new file mode 100644 index 00000000..c15bccc2 --- /dev/null +++ b/plugin.program.iptv.merge/resources/lib/constants.py @@ -0,0 +1,9 @@ +PLAYLIST_FILE_NAME = 'playlist.m3u8' +EPG_FILE_NAME = 'epg.xml' + +IPTV_SIMPLE_ID = 'pvr.iptvsimple' +INTEGRATIONS_URL = 'https://k.slyguy.xyz/.iptv_merge/data.json.gz' + +METHOD_PLAYLIST = 'playlist' +METHOD_EPG = 'epg' +MERGE_SETTING_FILE = '.iptv_merge' \ No newline at end of file diff --git a/plugin.program.iptv.merge/resources/lib/language.py b/plugin.program.iptv.merge/resources/lib/language.py new file mode 100644 index 00000000..a5383a9c --- /dev/null +++ b/plugin.program.iptv.merge/resources/lib/language.py @@ -0,0 +1,85 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + SETTING_FETCH_EVERY_X = 30000 + SETTING_AUTO_RELOAD = 30001 + SETTING_FILES_DIR = 30002 + SETTING_AUTO_MERGE = 30003 + SETTING_RELOAD = 30004 + SETTING_PAGESIZE = 30005 + SETUP_IPTV_SIMPLE = 30006 + SETUP_IPTV_COMPLETE = 30007 + SETTING_UP_IPTV = 30008 + PLAYLISTS = 30009 + ADD_PLAYLIST = 30010 + DELETE_PLAYLIST = 30011 + SOURCE_EXISTS = 30012 + URL = 30013 + FILE = 30014 + ADDON = 30015 + SELECT_SOURCE_TYPE = 30016 + ENTER_SOURCE_URL = 30017 + SELECT_SOURCE_FILE = 30018 + NO_SOURCE_ADDONS = 30019 + SELECT_SOURCE_ADDON = 30020 + ADD_EPG = 30021 + EDIT_PLAYLIST = 30022 + SOURCE_LABEL = 30023 + ARCHIVE_TYPE_LABEL = 30024 + NOT_ARCHIVED = 30025 + GZIP = 30026 + ENABLED_LABEL = 30027 + SKIP_PLIST_CHNO_LABEL = 30028 + START_CHNO_LABEL = 30029 + DEFAULT_VISIBILE_LABEL = 30030 + GROUP_NAME_LABEL = 30031 + SKIP_PLIST_GROUP_NAMES = 30032 + GROUP_LABEL = 30033 + ENTER_GROUP_NAME = 30034 + ENTER_START_CHNO = 30035 + SELECT_ARCHIVE_TYPE = 30036 + MANAGE_TV = 30037 + ALL_CHANNELS = 30038 + DELETE_EPG = 30039 + EDIT_EPG = 30040 + EPG_LABEL = 30041 + DISABLED = 30042 + MERGE_COMPLETE = 30043 + RUN_MERGE = 30044 + MERGE_STARTED = 30045 + MERGE_IN_PROGRESS = 30046 + ENTER_CHANNEL_URL = 30047 + NEXT_PAGE = 30048 + CHANNEL_LABEL = 30049 + CHANNEL_HIDDEN = 30050 + NO_NAME = 30051 + SHOW_CHANNEL = 30052 + HIDE_CHANNEL = 30053 + PLAY_CHANNEL = 30054 + DELETE_CHANNEL = 30055 + RESET_CHANNEL = 30056 + EDIT_CHANNEL = 30057 + CHANNEL_MODIFIED = 30058 + PLAYLIST = 30059 + ADD_CHANNEL = 30060 + EPGS = 30061 + ADDON_METHOD_TIMEOUT = 30062 + ADDON_METHOD_FAILED = 30063 + LOCAL_PATH_MISSING = 30064 + SLUG = 30065 + IPTV_MERGE_PROXY = 30066 + ADDON_SETTINGS = 30067 + PENDING_MERGE = 30068 + DISABLED_MERGE = 30069 + START_CHNO_SETTING = 30070 + MANAGE_RADIO = 30071 + RADIO_GROUP = 30072 + BOOT_MERGE = 30073 + XZ = 30074 + CLEAN_DBS = 30075 + INSERT_PLAYLIST = 30076 + ASK_TO_ADD = 30077 + GROUP_ORDER = 30078 + NO_FREE_IPTV = 30079 + +_ = Language() \ No newline at end of file diff --git a/plugin.program.iptv.merge/resources/lib/merger.py b/plugin.program.iptv.merge/resources/lib/merger.py new file mode 100644 index 00000000..e058b99e --- /dev/null +++ b/plugin.program.iptv.merge/resources/lib/merger.py @@ -0,0 +1,483 @@ +import os +import shutil +import time +import json +import codecs +import xml.parsers.expat +from xml.sax.saxutils import escape + +import peewee +from kodi_six import xbmc, xbmcvfs +from six.moves.urllib.parse import unquote + +from slyguy import settings, database, gui, router +from slyguy.log import log +from slyguy.util import remove_file, hash_6, FileIO, gzip_extract, xz_extract +from slyguy.session import Session +from slyguy.constants import ADDON_PROFILE, CHUNK_SIZE +from slyguy.exceptions import Error + +from .constants import * +from .models import Source, Playlist, EPG, Channel, merge_info, get_integrations +from .language import _ + +def copy_partial_data(file_path, _out, start_index, end_index): + if start_index < 1 or end_index < start_index: + return + + try: + with FileIO(file_path, 'rb', CHUNK_SIZE) as _in: + _seek_file(_in, start_index, truncate=False) + + while True: + size = min(CHUNK_SIZE, end_index - _in.tell()) + chunk = _in.read(size) + if not chunk: + break + + _out.write(chunk) + + return _in.tell() == end_index + except: + return + +def _seek_file(f, index, truncate=True): + cur_index = f.tell() + if cur_index != index: + log.debug('{} seeking from {} to {}'.format(f.name, cur_index, index)) + f.seek(index, os.SEEK_SET) + if truncate: + f.truncate() + +class AddonError(Error): + pass + +class XMLParser(object): + def __init__(self, out): + self._out = out + self.channel_count = 0 + self.programme_count = 0 + + self._parser = xml.parsers.expat.ParserCreate() + self._parser.buffer_text = True + self._parser.StartElementHandler = self._start_element + self._parser.EndElementHandler = self._end_element + + self._reset_buffer = False + self._start_index = None + self._end_index = None + + def _start_element(self, name, attrs): + if self._start_index == 'next': + self._start_index = self._parser.CurrentByteIndex + + if name == 'tv' and self._start_index is None: + self._start_index = 'next' + + if name == 'channel': + self.channel_count += 1 + + elif name == 'programme': + self.programme_count += 1 + + def _end_element(self, name): + self._reset_buffer = True + if name == 'tv': + self._end_index = self._parser.CurrentByteIndex + + def parse(self, _in, epg): + epg.start_index = self._out.tell() + + buffer = b'' + start_pos = 0 + while True: + chunk = _in.read(CHUNK_SIZE) + if not chunk: + break + + buffer += chunk + self._parser.Parse(chunk) + + if self._start_index in (None, 'next'): + continue + + if self._start_index: + buffer = buffer[self._start_index-start_pos:] + self._start_index = False + + if self._end_index: + buffer = buffer[:-(self._parser.CurrentByteIndex - self._end_index)] + + if self._reset_buffer: + self._out.write(buffer) + buffer = b'' + self._reset_buffer = False + start_pos = self._parser.CurrentByteIndex + + if self._end_index: + break + + self._out.flush() + epg.end_index = self._out.tell() + +class Merger(object): + def __init__(self, output_path=None, forced=False): + self.output_path = output_path or xbmc.translatePath(settings.get('output_dir', '').strip() or ADDON_PROFILE) + if not os.path.exists(self.output_path): + os.makedirs(self.output_path) + + self.forced = forced + self.tmp_file = os.path.join(self.output_path, 'iptv_merge_tmp') + self.integrations = get_integrations() + + def _call_addon_method(self, plugin_url): + dirs, files = xbmcvfs.listdir(plugin_url) + msg = unquote(files[0]) + if msg != 'ok': + raise AddonError(msg) + + def _process_source(self, source, method_name, file_path): + remove_file(file_path) + + path = source.path.strip() + source_type = source.source_type + archive_type = source.archive_type + + if source_type == Source.TYPE_ADDON: + addon_id = path + addon, data = merge_info(addon_id, self.integrations, merging=True) + + if method_name not in data: + raise Error('{} could not be found for {}'.format(method_name, addon_id)) + + template_tags = { + '$ID': addon_id, + '$FILE': file_path, + '$IP': xbmc.getIPAddress(), + } + + path = data[method_name] + for tag in template_tags: + path = path.replace(tag, template_tags[tag]) + + path = path.strip() + if path.lower().startswith('plugin'): + self._call_addon_method(path) + return + + if path.lower().startswith('http'): + source_type = Source.TYPE_URL + else: + source_type = Source.TYPE_FILE + + archive_extensions = { + '.gz': Source.ARCHIVE_GZIP, + '.xz': Source.ARCHIVE_XZ, + } + + name, ext = os.path.splitext(path.lower()) + archive_type = archive_extensions.get(ext, Source.ARCHIVE_NONE) + + if source_type == Source.TYPE_URL and path.lower().startswith('http'): + log.debug('Downloading: {} > {}'.format(path, file_path)) + Session().chunked_dl(path, file_path) + elif not xbmcvfs.exists(path): + raise Error(_(_.LOCAL_PATH_MISSING, path=path)) + else: + log.debug('Copying local file: {} > {}'.format(path, file_path)) + xbmcvfs.copy(path, file_path) + + if archive_type == Source.ARCHIVE_GZIP: + gzip_extract(file_path) + elif archive_type == Source.ARCHIVE_XZ: + xz_extract(file_path) + + def _process_playlist(self, playlist, file_path): + channel = None + to_create = set() + slugs = set() + added_count = 0 + + Channel.delete().where(Channel.playlist == playlist).execute() + + if playlist.use_start_chno: + chnos = {'tv': playlist.start_chno, 'radio': playlist.start_chno} + + free_iptv = False + with codecs.open(file_path, 'r', encoding='utf8', errors='replace') as infile: + for idx, line in enumerate(infile): + line = line.strip() + + if 'free-iptv' in line.lower(): + free_iptv = True + + if idx == 0 and '#EXTM3U' not in line: + raise Error('Invalid playlist - Does not start with #EXTM3U') + + if line.startswith('#EXTINF'): + channel = Channel.from_playlist(line) + elif not channel: + continue + + if line.startswith('#EXTGRP'): + value = line.split(':',1)[1].strip() + if value: + channel.groups.extend(value.split(';')) + + elif line.startswith('#KODIPROP') or line.startswith('#EXTVLCOPT'): + value = line.split(':',1)[1].strip() + if value and '=' in value: + key, value = value.split('=', 1) + channel.properties[key] = value + + elif line.startswith('#EXT-X-PLAYLIST-TYPE'): + value = line.split(':',1)[1].strip() + if value and value.upper() == 'VOD': + channel.is_live = False + + elif not line.startswith('#'): + channel.url = line + if not channel.url: + channel = None + continue + + channel.playlist = playlist + + if playlist.skip_playlist_groups: + channel.groups = [] + + if playlist.group_name: + channel.groups.extend(playlist.group_name.split(';')) + + if playlist.skip_playlist_chno: + channel.chno = None + + if playlist.use_start_chno: + if channel.radio: + if channel.chno is None: + channel.chno = chnos['radio'] + + chnos['radio'] = channel.chno + 1 + else: + if channel.chno is None: + channel.chno = chnos['tv'] + + chnos['tv'] = channel.chno + 1 + + if free_iptv: + channel.url = 'https://archive.org/download/Rick_Astley_Never_Gonna_Give_You_Up/Rick_Astley_Never_Gonna_Give_You_Up.mp4' + # channel.name = _.NO_FREE_IPTV + # channel.epg_id = None + # channel.logo = None + + channel.groups = [x for x in channel.groups if x.strip()] + channel.visible = playlist.default_visible + channel.slug = slug = '{}.{}'.format(playlist.id, hash_6(channel.epg_id or channel.url.lower().strip())) + channel.order = added_count + 1 + + count = 1 + while channel.slug in slugs: + channel.slug = '{}.{}'.format(slug, count) + count += 1 + + slugs.add(channel.slug) + to_create.add(channel) + + if Channel.bulk_create_lazy(to_create): + to_create.clear() + + channel = None + added_count += 1 + + Channel.bulk_create_lazy(to_create, force=True) + to_create.clear() + slugs.clear() + + return added_count + + def playlists(self): + start_time = time.time() + playlist_path = os.path.join(self.output_path, PLAYLIST_FILE_NAME) + database.connect() + + try: + progress = gui.progressbg() if self.forced else None + + playlists = list(Playlist.select().where(Playlist.enabled == True).order_by(Playlist.order)) + Playlist.update({Playlist.results: []}).where(Playlist.enabled == False).execute() + Channel.delete().where(Channel.custom == False, Channel.playlist.not_in(playlists)).execute() + + for count, playlist in enumerate(playlists): + count += 1 + + if progress: progress.update(int(count*(100/len(playlists))), 'Merging Playlist ({}/{})'.format(count, len(playlists)), _(playlist.label, _bold=True)) + + playlist_start = time.time() + + error = None + try: + log.debug('Processing: {}'.format(playlist.path)) + + if playlist.source_type != Playlist.TYPE_CUSTOM: + self._process_source(playlist, METHOD_PLAYLIST, self.tmp_file) + + with database.db.atomic() as transaction: + try: + added = self._process_playlist(playlist, self.tmp_file) + except: + transaction.rollback() + raise + else: + added = len(playlist.channels) + except AddonError as e: + error = e + except Error as e: + error = e + log.exception(e) + except Exception as e: + error = e + log.exception(e) + else: + playlist.results.insert(0, [int(time.time()), Playlist.OK, '{} Channels ({:.2f}s)'.format(added, time.time() - playlist_start)]) + error = None + + if error: + result = [int(time.time()), Playlist.ERROR, str(error)] + if playlist.results and playlist.results[0][1] == Playlist.ERROR: + playlist.results[0] = result + else: + playlist.results.insert(0, result) + + remove_file(self.tmp_file) + + playlist.results = playlist.results[:3] + playlist.save() + + count = 0 + starting_ch_no = settings.getInt('start_ch_no', 1) + + with codecs.open(playlist_path, 'w', encoding='utf8') as outfile: + outfile.write(u'#EXTM3U') + + group_order = settings.get('group_order') + if group_order: + outfile.write(u'\n\n#EXTGRP:{}'.format(group_order)) + + chno = starting_ch_no + tv_groups = [] + for channel in Channel.playlist_list(radio=False): + if channel.chno is None: + channel.chno = chno + chno = channel.chno + 1 + + tv_groups.extend(channel.groups) + + outfile.write(u'\n\n') + outfile.write(channel.get_lines()) + count += 1 + + chno = starting_ch_no + for channel in Channel.playlist_list(radio=True): + if channel.chno is None: + channel.chno = chno + chno = channel.chno + 1 + + new_groups = [] + for group in channel.groups: + count = 1 + while group in tv_groups: + group = _(_.RADIO_GROUP, group=group) + if count > 1: + group = u'{} #{}'.format(group, count) + count += 1 + new_groups.append(group) + + channel.groups = new_groups + + outfile.write(u'\n\n') + outfile.write(channel.get_lines()) + count += 1 + + log.debug('Wrote {} Channels'.format(count)) + Playlist.after_merge() + finally: + if progress: progress.close() + database.close() + + log.debug('Playlist Merge Time: {0:.2f}'.format(time.time() - start_time)) + + return playlist_path + + def epgs(self): + start_time = time.time() + epg_path = os.path.join(self.output_path, EPG_FILE_NAME) + epg_path_tmp = os.path.join(self.output_path, EPG_FILE_NAME+'_tmp') + database.connect() + + try: + progress = gui.progressbg() if self.forced else None + + epgs = list(EPG.select().where(EPG.enabled == True).order_by(EPG.id)) + EPG.update({EPG.start_index: 0, EPG.end_index: 0, EPG.results: []}).where(EPG.enabled == False).execute() + + with FileIO(epg_path_tmp, 'wb') as _out: + _out.write(b'') + + for count, epg in enumerate(epgs): + count += 1 + + if progress: progress.update(int(count*(100/len(epgs))), 'Merging EPG ({}/{})'.format(count, len(epgs)), _(epg.label, _bold=True)) + + file_index = _out.tell() + + epg_start = time.time() + try: + log.debug('Processing: {}'.format(epg.path)) + self._process_source(epg, METHOD_EPG, self.tmp_file) + with FileIO(self.tmp_file, 'rb') as _in: + parser = XMLParser(_out) + parser.parse(_in, epg) + except Exception as e: + log.exception(e) + result = [int(time.time()), EPG.ERROR, str(e)] + else: + result = [int(time.time()), EPG.OK, '{} Programmes ({:.2f}s)'.format(parser.programme_count, time.time() - epg_start)] + epg.results.insert(0, result) + + if result[1] == EPG.ERROR: + _seek_file(_out, file_index) + + if epg.start_index > 0: + if copy_partial_data(epg_path, _out, epg.start_index, epg.end_index): + log.debug('Last used XML data loaded successfully') + epg.start_index = file_index + epg.end_index = _out.tell() + else: + log.debug('Failed to load last XML data') + epg.start_index = 0 + epg.end_index = 0 + _seek_file(_out, file_index) + + if epg.results and epg.results[0][1] == EPG.ERROR: + epg.results[0] = result + else: + epg.results.insert(0, result) + + epg.results = epg.results[:3] + epg.save() + remove_file(self.tmp_file) + + _out.write(b'') + + remove_file(epg_path) + shutil.move(epg_path_tmp, epg_path) + finally: + if progress: progress.close() + + remove_file(self.tmp_file) + remove_file(epg_path_tmp) + database.close() + + log.debug('EPG Merge Time: {0:.2f}'.format(time.time() - start_time)) + + return epg_path \ No newline at end of file diff --git a/plugin.program.iptv.merge/resources/lib/models.py b/plugin.program.iptv.merge/resources/lib/models.py new file mode 100644 index 00000000..ef8267a6 --- /dev/null +++ b/plugin.program.iptv.merge/resources/lib/models.py @@ -0,0 +1,716 @@ +import os +import json +import time +import re +import codecs +import arrow +from contextlib import contextmanager +from distutils.version import LooseVersion + +import peewee +from six.moves.urllib_parse import urlparse, parse_qsl +from kodi_six import xbmc, xbmcgui, xbmcaddon + +from slyguy import database, gui, settings, plugin, inputstream +from slyguy.exceptions import Error +from slyguy.constants import ADDON_PROFILE, DEFAULT_USERAGENT +from slyguy.util import hash_6, get_addon, kodi_rpc +from slyguy.log import log +from slyguy.session import Session + +from .constants import * +from .language import _ + +@plugin.route() +def play_channel(slug, **kwargs): + channel = Channel.get_by_id(slug) + split = channel.url.split('|') + + headers = { + 'user-agent': DEFAULT_USERAGENT, + 'referer': '%20', + } + + if len(split) > 1: + _headers = dict(parse_qsl(u'{}'.format(split[1]), keep_blank_values=True)) + for key in _headers: + if _headers[key].startswith(' '): + _headers[key] = u'%20{}'.format(_headers[key][1:]) + + headers[key.lower()] = _headers[key] + + item = plugin.Item( + label = channel.name, + art = {'thumb': channel.logo}, + path = split[0], + properties = channel.properties, + headers = headers, + playable = True, + ) + + manifest_type = channel.properties.get('inputstream.adaptive.manifest_type', '') + license_type = channel.properties.get('inputstream.adaptive.license_type', '') + + if license_type.lower() == 'com.widevine.alpha': + inputstream.Widevine().check() + + elif manifest_type.lower() == 'hls': + inputstream.HLS(force=True, live=True).check() + + elif manifest_type.lower() == 'ism': + inputstream.Playready().check() + + elif manifest_type.lower() == 'mpd': + inputstream.MPD().check() + + elif not channel.radio and '.m3u8' in split[0].lower() and settings.getBool('use_ia_hls_live'): + item.inputstream = inputstream.HLS(live=True) + + return item + +class Source(database.Model): + ERROR = 0 + OK = 1 + + TYPE_URL = 0 + TYPE_FILE = 1 + TYPE_ADDON = 2 + TYPE_CUSTOM = 3 + + ARCHIVE_NONE = 0 + ARCHIVE_GZIP = 1 + ARCHIVE_XZ = 2 + + source_type = peewee.IntegerField() + archive_type = peewee.IntegerField(default=ARCHIVE_NONE) + + path = peewee.CharField() + enabled = peewee.BooleanField(default=True) + + results = database.JSONField(default=list) + + TYPES = [TYPE_URL, TYPE_FILE, TYPE_ADDON, TYPE_CUSTOM] + TYPE_LABELS = { + TYPE_URL: _.URL, + TYPE_FILE: _.FILE, + TYPE_ADDON: _.ADDON, + TYPE_CUSTOM: 'Custom', + } + + def save(self, *args, **kwargs): + try: + super(Source, self).save(*args, **kwargs) + except peewee.IntegrityError as e: + raise Error(_.SOURCE_EXISTS) + + @property + def plot(self): + plot = u'' + + if not self.enabled: + plot = _.DISABLED_MERGE + elif not self.results: + plot = _.PENDING_MERGE + else: + for result in self.results: + _time = arrow.get(result[0]).to('local').format('DD/MM/YY h:mm:ss a') + _result = u'{}'.format(_(result[2], _color='lightgreen' if result[1] == self.OK else 'red')) + plot += _(u'{}\n{}\n\n'.format(_time, _result)) + + return plot + + @property + def thumb(self): + if self.source_type == self.TYPE_ADDON: + try: + return xbmcaddon.Addon(self.path).getAddonInfo('icon') + except: + return None + else: + return None + + @property + def label(self): + if self.source_type == self.TYPE_ADDON: + try: + label = xbmcaddon.Addon(self.path).getAddonInfo('name') + except: + label = '{} (Unknown)'.format(self.path) + else: + label = self.path + + return label + + @property + def name(self): + if not self.enabled: + name = _(_.DISABLED, label=self.label, _color='gray') + elif not self.results: + name = _(self.label, _color='orange') + elif self.results[0][1] == self.OK: + name = _(self.label, _color='lightgreen') + else: + name = _(self.label, _color='red', _bold=True) + + return name + + @classmethod + def user_create(cls): + obj = cls() + + if obj.select_path(creating=True): + return obj + + return None + + def select_path(self, creating=False): + try: + default = self.TYPES.index(self.source_type) + except: + default = -1 + + index = gui.select(_.SELECT_SOURCE_TYPE, [self.TYPE_LABELS[x] for x in self.TYPES], preselect=default) + if index < 0: + return False + + orig_source_type = self.source_type + self.source_type = self.TYPES[index] + + if self.source_type == self.TYPE_ADDON: + addons = self.get_addon_sources(current=self.path if orig_source_type == self.TYPE_ADDON else None) + if not addons: + raise Error(_.NO_SOURCE_ADDONS) + + options = [] + default = -1 + addons.sort(key=lambda x: x[0].getAddonInfo('name').lower()) + + for idx, row in enumerate(addons): + options.append(plugin.Item(label=row[0].getAddonInfo('name'), art={'thumb': row[0].getAddonInfo('icon')})) + if orig_source_type == self.TYPE_ADDON and row[0].getAddonInfo('id') == self.path: + default = idx + + index = gui.select(_.SELECT_SOURCE_ADDON, options, preselect=default, useDetails=True) + if index < 0: + return False + + addon, data = addons[index] + self.path = addon.getAddonInfo('id') + elif self.source_type == self.TYPE_URL: + self.path = gui.input(_.ENTER_SOURCE_URL, default=self.path if orig_source_type == self.TYPE_URL else '').strip() + elif self.source_type == self.TYPE_FILE: + self.path = xbmcgui.Dialog().browseSingle(1, _.SELECT_SOURCE_FILE, '', '', defaultt=self.path if orig_source_type == self.TYPE_FILE else '') + elif self.source_type == self.TYPE_CUSTOM: + self.path = gui.input('Custom Name', default=self.path if orig_source_type == self.TYPE_CUSTOM else '').strip() + + if not self.path: + return False + + self.auto_archive_type() + self.save() + + if self.source_type == self.TYPE_ADDON: + for key in data.get('settings', {}): + value = data['settings'][key].replace('$ID', self.path) + log.debug('Set setting {}={} for addon {}'.format(key, value, self.path)) + addon.setSetting(key, value) + + if creating: + if self.__class__ == Playlist and METHOD_EPG in data: + epg = EPG(source_type=EPG.TYPE_ADDON, path=self.path) + try: epg.save() + except: pass + + elif self.__class__ == EPG and METHOD_PLAYLIST in data: + playlist = Playlist(source_type=Playlist.TYPE_ADDON, path=self.path) + try: playlist.save() + except: pass + + return True + + def auto_archive_type(self): + archive_extensions = { + '.gz': self.ARCHIVE_GZIP, + '.xz': self.ARCHIVE_XZ, + } + + name, ext = os.path.splitext(self.path.lower()) + self.archive_type = archive_extensions.get(ext, self.ARCHIVE_NONE) + + def select_archive_type(self): + values = [self.ARCHIVE_NONE, self.ARCHIVE_GZIP, self.ARCHIVE_XZ] + labels = [_.NOT_ARCHIVED, _.GZIP, _.XZ] + + try: + default = values.index(self.archive_type) + except: + default = 0 + + index = gui.select(_.SELECT_ARCHIVE_TYPE, labels, preselect=default) + if index < 0: + return False + + self.archive_type = values[index] + return True + + @property + def archive_type_name(self): + if self.archive_type == self.ARCHIVE_GZIP: + return _.GZIP + elif self.archive_type == self.ARCHIVE_XZ: + return _.XZ + else: + return _.NOT_ARCHIVED + + def toggle_enabled(self): + self.enabled = not self.enabled + return True + + @classmethod + def has_sources(cls): + return cls.select().where(cls.enabled == True).exists() + + @classmethod + def wizard(cls): + source = cls() + if not source.select_path(): + return + + return source + + @classmethod + def get_addon_sources(cls, current=None): + data = kodi_rpc('Addons.GetAddons', {'installed': True, 'enabled': True}, raise_on_error=True) + integrations = get_integrations() + installed = [x.path for x in cls.select(cls.path).where(cls.source_type==cls.TYPE_ADDON)] + + addons = [] + for row in data['addons']: + if row['addonid'] != current and row['addonid'] in installed: + continue + + addon, data = merge_info(row['addonid'], integrations) + if not addon or not data: + continue + + if cls == Playlist and METHOD_PLAYLIST not in data: + continue + elif cls == EPG and METHOD_EPG not in data: + continue + + addons.append([addon, data]) + + return addons + + class Meta: + indexes = ( + (('path',), True), + ) + +def get_integrations(): + try: + return Session().gz_json(INTEGRATIONS_URL) + except Exception as e: + log.debug('Failed to get integrations') + log.exception(e) + return {} + +def merge_info(addon_id, integrations=None, merging=False): + addon = get_addon(addon_id, required=True, install=False) + addon_path = xbmc.translatePath(addon.getAddonInfo('path')) + merge_path = os.path.join(addon_path, MERGE_SETTING_FILE) + + if os.path.exists(merge_path): + try: + with codecs.open(merge_path, 'r', encoding='utf8') as f: + data = json.load(f) + except Exception as e: + log.exception(e) + log.debug('failed to parse merge file: {}'.format(merge_path)) + return addon, {} + else: + if integrations is None: + integrations = get_integrations() + + data = integrations.get(addon_id) or {} + + if merging: + if not integrations: + raise Error('Failed to download integrations') + + elif not data: + raise Error('No integration found for this source') + + min_version = data.get('min_version') + max_version = data.get('max_version') + current_version = LooseVersion(addon.getAddonInfo('version')) + + if min_version and current_version < LooseVersion(min_version): + if merging: + raise Error('Min version {} required'.format(min_version)) + else: + data = {} + + if max_version and current_version > LooseVersion(max_version): + if merging: + raise Error('Max version {} exceeded'.format(max_version)) + else: + data = {} + + return addon, data + +class EPG(Source): + TYPES = [Source.TYPE_URL, Source.TYPE_FILE, Source.TYPE_ADDON] + + start_index = peewee.IntegerField(default=0) + end_index = peewee.IntegerField(default=0) + +class Playlist(Source): + skip_playlist_chno = peewee.BooleanField(default=False) + use_start_chno = peewee.BooleanField(default=False) + start_chno = peewee.IntegerField(default=1) + default_visible = peewee.BooleanField(default=True) + skip_playlist_groups = peewee.BooleanField(default=False) + group_name = peewee.CharField(null=True) + order = peewee.IntegerField() + + def save(self, *args, **kwargs): + if not self.order: + self.order = Playlist.select(peewee.fn.MAX(Playlist.order)+1).scalar() or 1 + + if not self.id and self.source_type == Source.TYPE_ADDON and not self.group_name: + try: self.group_name = xbmcaddon.Addon(self.path).getAddonInfo('name') + except: pass + + super(Playlist, self).save(*args, **kwargs) + + def get_epg(self): + try: + return self.epgs.get() + except EPG.DoesNotExist: + return None + + def select_start_chno(self): + start_chno = gui.numeric(_.ENTER_START_CHNO, default=self.start_chno) + if start_chno is None: + return False + + self.start_chno = start_chno + return True + + def select_group_name(self): + self.group_name = gui.input(_.ENTER_GROUP_NAME, default=self.group_name) or None + return True + + def toggle_use_start_chno(self): + self.use_start_chno = not self.use_start_chno + return True + + def toggle_skip_playlist_chno(self): + self.skip_playlist_chno = not self.skip_playlist_chno + return True + + def toggle_default_visible(self): + self.default_visible = not self.default_visible + return True + + def toggle_skip_playlist_groups(self): + self.skip_playlist_groups = not self.skip_playlist_groups + return True + + @classmethod + def after_merge(cls): + Override.clean() + +class Channel(database.Model): + slug = peewee.CharField(primary_key=True) + playlist = peewee.ForeignKeyField(Playlist, backref="channels", on_delete='cascade') + url = peewee.CharField() + order = peewee.IntegerField() + chno = peewee.IntegerField(null=True) + name = peewee.CharField(null=True) + custom = peewee.BooleanField(default=False) + + groups = database.JSONField(default=list) + radio = peewee.BooleanField(default=False) + epg_id = peewee.CharField(null=True) + logo = peewee.CharField(null=True) + attribs = database.JSONField(default=dict) + properties = database.JSONField(default=dict) + visible = peewee.IntegerField(default=True) + is_live = peewee.BooleanField(default=True) + + modified = peewee.BooleanField(default=False) + + @property + def label(self): + label = '' + if self.chno is not None: + label = '{} - '.format(self.chno) + + label += self.name or _.NO_NAME + + return label + + @property + def plot(self): + plot = u'{}\n{}'.format(_.URL, self.url) + + # if self.groups: + # plot += u'\n\n{}\n{}'.format('Groups', '\n'.join(self.groups)) + + if self.playlist_id: + plot += u'\n\n{}\n{}'.format(_.PLAYLIST, self.playlist.path) + + if self.epg_id: + plot += u'\n\n{}\n{}'.format('EPG ID', self.epg_id) + + return plot + + def get_play_path(self): + if self.url.lower().startswith('http') and settings.getBool('iptv_merge_proxy', True): + return plugin.url_for(play_channel, slug=self.slug) + else: + return self.url + + def get_lines(self): + lines = u'#EXTINF:-1' + + attribs = self.attribs.copy() + attribs.update({ + 'tvg-id': self.epg_id, + 'group-title': ';'.join([x for x in self.groups if x.strip()]) if self.groups else None, + 'tvg-chno': self.chno, + 'tvg-logo': self.logo, + 'radio' : 'true' if self.radio else None, + }) + + for key in sorted(attribs.keys()): + value = attribs[key] + if value is not None: + lines += u' {}="{}"'.format(key, value) + + lines += u',{}\n'.format(self.name if self.name else '') + + if not self.is_live: + lines += u'#EXT-X-PLAYLIST-TYPE:VOD\n' + + if not self.url.lower().startswith('http') or not settings.getBool('iptv_merge_proxy', True): + for key in self.properties: + lines += u'#KODIPROP:{}={}\n'.format(key, self.properties[key]) + + lines += u'{}'.format(self.get_play_path()) + + return lines + + @classmethod + def playlist_list(cls, radio=False): + query = cls.select(cls).join(Playlist).where(cls.visible == True, cls.radio==radio).order_by(cls.chno.asc(nulls='LAST'), cls.playlist.order, cls.order) + + with cls.merged(): + for channel in query: + yield(channel) + + @classmethod + def channel_list(cls, radio=None, playlist_id=0, page=1, page_size=0, search=None): + query = cls.select(cls).join(Playlist).order_by(cls.chno.asc(nulls='LAST'), cls.playlist.order, cls.order) + + if radio is not None: + query = query.where(cls.radio == radio) + + if playlist_id is None: + query = query.where(cls.playlist_id.is_null()) + elif playlist_id: + query = query.where(cls.playlist_id == playlist_id) + + if search: + query = query.where(cls.name.concat(' ').concat(cls.url) ** '%{}%'.format(search)) + + if page_size > 0: + query = query.paginate(page, page_size) + + with cls.merged(): + for channel in query.prefetch(Playlist): + yield(channel) + + @classmethod + @contextmanager + def merged(cls): + channel_updates = set() + + for override in Override.select(Override, Channel).join(Channel, on=(Channel.slug == Override.slug), attr='channel'): + channel = override.channel + + for key in override.fields: + if hasattr(channel, key): + setattr(channel, key, override.fields[key]) + else: + log.debug('Skipping unknown override key: {}'.format(key)) + + channel.modified = True if not channel.custom else False + channel.attribs.update(override.attribs) + channel.properties.update(override.properties) + channel_updates.add(channel) + + if not channel_updates: + yield + return + + with database.db.atomic() as transaction: + try: + Channel.bulk_update(channel_updates, fields=Channel._meta.fields) + yield + transaction.rollback() + except Exception as e: + transaction.rollback() + raise + + @classmethod + def from_url(cls, playlist, url): + order = Channel.select(peewee.fn.MAX(Channel.order)+1).where(Channel.playlist == playlist).scalar() or 1 + + return Channel( + playlist = playlist, + slug = '{}.{}'.format(playlist.id, hash_6(time.time(), url.lower().strip())), + url = url, + name = url, + order = order, + custom = True, + ) + + @classmethod + def from_playlist(cls, extinf): + colon = extinf.find(':', 0) + comma = extinf.rfind(',', 0) + + name = None + if colon >= 0 and comma >= 0 and comma > colon: + name = extinf[comma+1:].strip() + + attribs = {} + for key, value in re.findall('([\w-]+)="([^"]*)"', extinf): + attribs[key] = value.strip() + + is_radio = attribs.pop('radio', 'false').lower() == 'true' + + try: + chno = int(attribs.pop('tvg-chno')) + except: + chno = None + + groups = attribs.pop('group-title', '').strip() + + if groups: + groups = groups.split(';') + else: + groups = [] + + channel = Channel( + chno = chno, + name = name, + groups = groups, + radio = is_radio, + epg_id = attribs.pop('tvg-id', None), + logo = attribs.pop('tvg-logo', None), + ) + + channel.attribs = attribs + + return channel + +class Override(database.Model): + playlist = peewee.ForeignKeyField(Playlist, backref="overrides", on_delete='cascade') + slug = peewee.CharField(primary_key=True) + fields = database.JSONField(default=dict) + attribs = database.JSONField(default=dict) + properties = database.JSONField(default=dict) + headers = database.JSONField(default=dict) + + def edit_logo(self, channel): + self.fields['logo'] = self.fields.get('logo', channel.logo) + new_value = gui.input('Channel Logo', default=self.fields['logo']) + + if new_value == channel.logo: + self.fields.pop('logo') + elif new_value: + self.fields['logo'] = new_value + else: + return False + + return True + + def edit_name(self, channel): + self.fields['name'] = self.fields.get('name', channel.name) + new_value = gui.input('Channel Name', default=self.fields['name']) + + if new_value == channel.name: + self.fields.pop('name') + elif new_value: + self.fields['name'] = new_value + else: + return False + + return True + + def edit_chno(self, channel): + self.fields['chno'] = self.fields.get('chno', channel.chno) + new_chno = gui.numeric('Channel Number', default=self.fields['chno'] if self.fields['chno'] != None else '') + + try: new_chno = int(new_chno) + except: new_chno = None + + if new_chno == channel.chno: + self.fields.pop('chno') + elif new_chno: + self.fields['chno'] = new_chno + else: + return False + + return True + + def edit_groups(self, channel): + self.fields['groups'] = self.fields.get('groups', channel.groups) + new_groups = gui.input('Channel Groups', default=';'.join(self.fields['groups']) if self.fields['groups'] else '').split(';') + + if new_groups == channel.groups: + self.fields.pop('groups') + elif new_groups: + self.fields['groups'] = new_groups + else: + return False + + return True + + def edit_epg_id(self, channel): + self.fields['epg_id'] = self.fields.get('epg_id', channel.epg_id) + new_id = gui.input('EPG ID', default=self.fields['epg_id']) + + if new_id == channel.epg_id: + self.fields.pop('epg_id') + elif new_id: + self.fields['epg_id'] = new_id + else: + return False + + return True + + def toggle_visible(self, channel): + self.fields['visible'] = not self.fields.get('visible', channel.visible) + + if self.fields['visible'] == channel.visible: + self.fields.pop('visible', None) + + return True + + def save(self, *args, **kwargs): + if not self.fields and not self.attribs and not self.properties: + self.delete_instance() + else: + super(Override, self).save(*args, **kwargs) + + @classmethod + def clean(cls): + slugs = cls.select(cls.slug).join(Channel, on=(Channel.slug == cls.slug)) + cls.delete().where((cls.slug.not_in(slugs)) | ((cls.fields=={}) & (cls.attribs=={}) & (cls.properties=={}))).execute() + +database.tables.extend([Playlist, EPG, Channel, Override]) \ No newline at end of file diff --git a/plugin.program.iptv.merge/resources/lib/plugin.py b/plugin.program.iptv.merge/resources/lib/plugin.py new file mode 100644 index 00000000..81ae0012 --- /dev/null +++ b/plugin.program.iptv.merge/resources/lib/plugin.py @@ -0,0 +1,678 @@ +import os +import json +import time +from difflib import SequenceMatcher + +from kodi_six import xbmc, xbmcaddon, xbmcgui + +from slyguy import plugin, settings, gui, userdata +from slyguy.util import set_kodi_setting, kodi_rpc, set_kodi_string, get_kodi_string, get_addon +from slyguy.constants import ADDON_PROFILE, KODI_VERSION, ADDON_ICON +from slyguy.exceptions import PluginError + +from .language import _ +from .models import Playlist, Source, EPG, Channel, Override, play_channel, merge_info +from .constants import * +from .merger import Merger + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not iptv_is_setup(): + folder.add_item( + label = _(_.SETUP_IPTV_SIMPLE, _bold=True), + path = plugin.url_for(setup), + ) + + folder.add_item( + label = _.PLAYLISTS, + path = plugin.url_for(playlists), + ) + + folder.add_item( + label = _.EPGS, + path = plugin.url_for(epgs), + ) + + folder.add_item( + label = _.MANAGE_TV, + path = plugin.url_for(manager, radio=0), + ) + + folder.add_item( + label = _.MANAGE_RADIO, + path = plugin.url_for(manager, radio=1), + ) + + folder.add_item( + label = _.RUN_MERGE, + path = plugin.url_for(merge), + ) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_.BOOKMARKS, path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False) + + return folder + +@plugin.route() +def shift_playlist(playlist_id, shift, **kwargs): + shift = int(shift) + playlist = Playlist.get_by_id(int(playlist_id)) + + Playlist.update(order = Playlist.order - shift).where(Playlist.order == playlist.order + shift).execute() + playlist.order += shift + playlist.save() + + gui.refresh() + +@plugin.route() +def playlists(**kwargs): + folder = plugin.Folder(_.PLAYLISTS) + + playlists = Playlist.select().order_by(Playlist.order) + + for playlist in playlists: + context = [ + ('Disable Playlist' if playlist.enabled else 'Enable Playlist', "RunPlugin({})".format(plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.toggle_enabled.__name__))), + (_.DELETE_PLAYLIST, "RunPlugin({})".format(plugin.url_for(delete_playlist, playlist_id=playlist.id))), + (_.INSERT_PLAYLIST, "RunPlugin({})".format(plugin.url_for(new_playlist, position=playlist.order))), + ] + + if playlist.source_type == Playlist.TYPE_ADDON: + context.append((_.ADDON_SETTINGS, "Addon.OpenSettings({})".format(playlist.path))) + + if playlist.order > 1: + context.append(('Move Up', 'RunPlugin({})'.format(plugin.url_for(shift_playlist, playlist_id=playlist.id, shift=-1)))) + + if playlist.order < len(playlists)+1: + context.append(('Move Down', 'RunPlugin({})'.format(plugin.url_for(shift_playlist, playlist_id=playlist.id, shift=1)))) + + folder.add_item( + label = playlist.name, + info = {'plot': playlist.plot}, + art = {'thumb': playlist.thumb}, + path = plugin.url_for(edit_playlist, playlist_id=playlist.id), + context = context, + ) + + folder.add_item( + label = _(_.ADD_PLAYLIST, _bold=True), + path = plugin.url_for(new_playlist), + ) + + return folder + +@plugin.route() +def epgs(**kwargs): + folder = plugin.Folder(_.EPGS) + + for epg in EPG.select().order_by(EPG.id): + context = [ + ('Disable EPG' if epg.enabled else 'Enable EPG', "RunPlugin({})".format(plugin.url_for(edit_epg_value, epg_id=epg.id, method=EPG.toggle_enabled.__name__))), + (_.DELETE_EPG, "RunPlugin({})".format(plugin.url_for(delete_epg, epg_id=epg.id))), + ] + + if epg.source_type == EPG.TYPE_ADDON: + context.append(('Add-on Settings', "Addon.OpenSettings({})".format(epg.path))) + + folder.add_item( + label = epg.name, + info = {'plot': epg.plot}, + art = {'thumb': epg.thumb}, + path = plugin.url_for(edit_epg, epg_id=epg.id), + context = context, + ) + + folder.add_item( + label = _(_.ADD_EPG, _bold=True), + path = plugin.url_for(new_epg), + ) + + return folder + +@plugin.route() +def delete_playlist(playlist_id, **kwargs): + if not gui.yes_no('Are you sure you want to delete this playlist?'): + return + + playlist_id = int(playlist_id) + playlist = Playlist.get_by_id(playlist_id) + playlist.delete_instance() + Playlist.update(order = Playlist.order - 1).where(Playlist.order >= playlist.order).execute() + + gui.refresh() + +@plugin.route() +def delete_epg(epg_id, **kwargs): + if not gui.yes_no('Are you sure you want to delete this EPG?'): + return + + epg_id = int(epg_id) + epg = EPG.get_by_id(epg_id) + epg.delete_instance() + + gui.refresh() + +@plugin.route() +def new_playlist(position=None, **kwargs): + playlist = Playlist.user_create() + if not playlist: + return + + if position: + position = int(position) + Playlist.update(order = Playlist.order + 1).where(Playlist.order >= position).execute() + playlist.order = position + playlist.save() + + if settings.getBool('ask_to_add', True) and playlist.source_type != Playlist.TYPE_ADDON and gui.yes_no(_.ADD_EPG): + EPG.user_create() + + gui.refresh() + +@plugin.route() +def new_epg(**kwargs): + epg = EPG.user_create() + if not epg: + return + + if settings.getBool('ask_to_add', True) and epg.source_type != EPG.TYPE_ADDON and gui.yes_no(_.ADD_PLAYLIST): + Playlist.user_create() + + gui.refresh() + +@plugin.route() +def open_settings(addon_id, **kwargs): + get_addon(addon_id, required=True).openSettings() + +@plugin.route() +def edit_playlist(playlist_id, **kwargs): + playlist_id = int(playlist_id) + playlist = Playlist.get_by_id(playlist_id) + + folder = plugin.Folder(playlist.label, thumb=playlist.thumb) + + folder.add_item( + label = _(_.SOURCE_LABEL, value=playlist.label), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.select_path.__name__), + ) + + folder.add_item( + label = _(_.ENABLED_LABEL, value=playlist.enabled), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.toggle_enabled.__name__), + ) + + if playlist.source_type == Playlist.TYPE_CUSTOM: + return folder + + if playlist.source_type == Playlist.TYPE_ADDON: + folder.add_item( + label = _.ADDON_SETTINGS, + path = plugin.url_for(open_settings, addon_id=playlist.path), + ) + else: + folder.add_item( + label = _(_.ARCHIVE_TYPE_LABEL, value=playlist.archive_type_name), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.select_archive_type.__name__), + ) + + folder.add_item( + label = _(_.SKIP_PLIST_CHNO_LABEL, value=playlist.skip_playlist_chno), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.toggle_skip_playlist_chno.__name__), + ) + + folder.add_item( + label = _('Use Starting Channel Number: {value}', value=playlist.use_start_chno), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.toggle_use_start_chno.__name__), + ) + + if playlist.use_start_chno: + folder.add_item( + label = _(_.START_CHNO_LABEL, value=playlist.start_chno), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.select_start_chno.__name__), + ) + + folder.add_item( + label = _(_.DEFAULT_VISIBILE_LABEL, value=playlist.default_visible), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.toggle_default_visible.__name__), + ) + + folder.add_item( + label = _(_.SKIP_PLIST_GROUP_NAMES, value=playlist.skip_playlist_groups), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.toggle_skip_playlist_groups.__name__), + ) + + folder.add_item( + label = _(_.GROUP_LABEL, value=playlist.group_name), + path = plugin.url_for(edit_playlist_value, playlist_id=playlist.id, method=Playlist.select_group_name.__name__), + ) + + return folder + +@plugin.route() +def edit_playlist_value(playlist_id, method, **kwargs): + playlist_id = int(playlist_id) + playlist = Playlist.get_by_id(playlist_id) + + method = getattr(playlist, method) + if method(): + playlist.save() + gui.refresh() + +@plugin.route() +def edit_epg(epg_id, **kwargs): + epg_id = int(epg_id) + epg = EPG.get_by_id(epg_id) + + folder = plugin.Folder(epg.label, thumb=epg.thumb) + + folder.add_item( + label = _(_.SOURCE_LABEL, value=epg.label), + path = plugin.url_for(edit_epg_value, epg_id=epg.id, method=EPG.select_path.__name__), + ) + + folder.add_item( + label = _(_.ENABLED_LABEL, value=epg.enabled), + path = plugin.url_for(edit_epg_value, epg_id=epg.id, method=EPG.toggle_enabled.__name__), + ) + + if epg.source_type == EPG.TYPE_ADDON: + folder.add_item( + label = _.ADDON_SETTINGS, + path = plugin.url_for(open_settings, addon_id=epg.path), + ) + else: + folder.add_item( + label = _(_.ARCHIVE_TYPE_LABEL, value=epg.archive_type_name), + path = plugin.url_for(edit_epg_value, epg_id=epg.id, method=EPG.select_archive_type.__name__), + ) + + return folder + +@plugin.route() +def edit_epg_value(epg_id, method, **kwargs): + epg_id = int(epg_id) + epg = EPG.get_by_id(epg_id) + + method = getattr(epg, method) + if method(): + epg.save() + gui.refresh() + +@plugin.route() +def manager(radio=0, **kwargs): + radio = int(radio) + + if radio: + folder = plugin.Folder(_.MANAGE_RADIO) + else: + folder = plugin.Folder(_.MANAGE_TV) + + for playlist in Playlist.select().where(Playlist.enabled == True).order_by(Playlist.order): + folder.add_item( + label = playlist.label, + art = {'thumb': playlist.thumb}, + path = plugin.url_for(playlist_channels, playlist_id=playlist.id, radio=radio), + ) + + folder.add_item( + label = _(_.ALL_CHANNELS, _bold=True), + path = plugin.url_for(channels, radio=radio), + ) + + folder.add_item( + label = _(_.SEARCH, _bold=True), + path = plugin.url_for(search_channel, radio=radio), + ) + + # folder.add_item( + # label = 'Groups', + # path = plugin.url_for(group_manager, radio=radio), + # ) + + # folder.add_item( + # label = 'EPG', + # path = plugin.url_for(epg_manager, radio=radio), + # ) + + return folder + +@plugin.route() +def channels(radio=0, page=1, **kwargs): + folder = plugin.Folder(_.ALL_CHANNELS) + + radio = int(radio) + page = int(page) + page_size = settings.getInt('page_size', 0) + + query = Channel.channel_list(radio=radio, page=page, page_size=page_size) + + items = _process_channels(query) + folder.add_items(items) + + if len(items) == page_size: + folder.add_item( + label = _(_.NEXT_PAGE, page=page+1, _bold=True), + path = plugin.url_for(channels, radio=radio, page=page+1), + ) + + return folder + +def _process_channels(query): + items = [] + + for channel in query: + context = [] + + label = channel.label + + if channel.modified: + label = _(_.CHANNEL_MODIFIED, label=label) + + if not channel.visible: + label = _(_.CHANNEL_HIDDEN, label=label) + + if channel.url: + context.append((_.PLAY_CHANNEL, "PlayMedia({})".format(channel.get_play_path()))) + + context.append((_.HIDE_CHANNEL if channel.visible else _.SHOW_CHANNEL, + "RunPlugin({})".format(plugin.url_for(edit_channel_value, slug=channel.slug, method=Override.toggle_visible.__name__)))) + + #context.append((_.EDIT_CHANNEL, "RunPlugin({})".format(plugin.url_for(edit_channel, slug=channel.slug)))) + + context.append(('Channel Number', "RunPlugin({})".format(plugin.url_for(edit_channel_value, slug=channel.slug, method=Override.edit_chno.__name__)))) + context.append(('Channel Name', "RunPlugin({})".format(plugin.url_for(edit_channel_value, slug=channel.slug, method=Override.edit_name.__name__)))) + context.append(('Channel Logo', "RunPlugin({})".format(plugin.url_for(edit_channel_value, slug=channel.slug, method=Override.edit_logo.__name__)))) + context.append(('Channel Groups', "RunPlugin({})".format(plugin.url_for(edit_channel_value, slug=channel.slug, method=Override.edit_groups.__name__)))) + context.append(('EPG ID', "RunPlugin({})".format(plugin.url_for(edit_channel_value, slug=channel.slug, method=Override.edit_epg_id.__name__)))) + + if channel.custom: + context.append((_.DELETE_CHANNEL, "RunPlugin({})".format(plugin.url_for(reset_channel, slug=channel.slug)))) + elif channel.modified: + context.append((_.RESET_CHANNEL, "RunPlugin({})".format(plugin.url_for(reset_channel, slug=channel.slug)))) + + items.append(plugin.Item( + label = label, + art = {'thumb': channel.logo}, + info = {'plot': channel.plot}, + # path = plugin.url_for(edit_channel, slug=channel.slug), + path = plugin.url_for(edit_channel_value, slug=channel.slug, method=Override.toggle_visible.__name__), + context = context, + is_folder = True, + )) + + return items + +@plugin.route() +def edit_channel(slug, **kwargs): + pass + +@plugin.route() +def reset_channel(slug, **kwargs): + channel = Channel.get_by_id(slug) + + if channel.custom: + if not gui.yes_no('Are you sure you want to delete this channel?'): + return + + channel.delete_instance() + + Override.delete().where(Override.slug == channel.slug).execute() + + gui.refresh() + +@plugin.route() +def edit_channel_value(slug, method, **kwargs): + channel = Channel.select(Channel, Override).where(Channel.slug == slug).join(Override, join_type="LEFT OUTER JOIN", on=(Channel.slug == Override.slug), attr='override').get() + + create = False + if not hasattr(channel, 'override'): + channel.override = Override(playlist=channel.playlist, slug=channel.slug) + create = True + + method = getattr(channel.override, method) + if method(channel): + channel.override.save(force_insert=create) + gui.refresh() + +@plugin.route() +def search_channel(query=None, radio=0, page=1, **kwargs): + radio = int(radio) + page = int(page) + + if not query: + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + + page_size = settings.getInt('page_size', 0) + db_query = Channel.channel_list(radio=radio, page=page, search=query, page_size=page_size) + + items = _process_channels(db_query) + folder.add_items(items) + + if len(items) == page_size: + folder.add_item( + label = _(_.NEXT_PAGE, page=page+1, _bold=True), + path = plugin.url_for(search_channel, query=query, radio=radio, page=page+1), + ) + + return folder + +@plugin.route() +def playlist_channels(playlist_id, radio=0, page=1, **kwargs): + playlist_id = int(playlist_id) + radio = int(radio) + page = int(page) + + playlist = Playlist.get_by_id(playlist_id) + + folder = plugin.Folder(playlist.label) + + page_size = settings.getInt('page_size', 0) + db_query = Channel.channel_list(playlist_id=playlist_id, radio=radio, page=page, page_size=page_size) + + items = _process_channels(db_query) + folder.add_items(items) + + if len(items) == page_size: + folder.add_item( + label = _(_.NEXT_PAGE, page=page+1, _bold=True), + path = plugin.url_for(playlist_channels, playlist_id=playlist_id, radio=radio, page=page+1), + ) + + if playlist.source_type == Playlist.TYPE_CUSTOM: + folder.add_item( + label = _(_.ADD_CHANNEL, _bold=True), + path = plugin.url_for(add_channel, playlist_id=playlist_id, radio=radio), + ) + + return folder + +@plugin.route() +def add_channel(playlist_id, radio, **kwargs): + playlist_id = int(playlist_id) + radio = int(radio) + + url = gui.input(_.ENTER_CHANNEL_URL) + if not url: + return + + playlist = Playlist.get_by_id(playlist_id) + + channel = Channel.from_url(playlist, url) + channel.radio = radio + channel.save(force_insert=True) + + gui.refresh() + +def iptv_is_setup(): + addon = get_addon(IPTV_SIMPLE_ID, required=False, install=False) + if not addon: + return False + + output_dir = xbmc.translatePath(settings.get('output_dir', '').strip() or ADDON_PROFILE) + playlist_path = os.path.join(output_dir, PLAYLIST_FILE_NAME) + epg_path = os.path.join(output_dir, EPG_FILE_NAME) + + return addon.getSetting('m3uPathType') == '0' and addon.getSetting('epgPathType') == '0' \ + and addon.getSetting('m3uPath') == playlist_path and addon.getSetting('epgPath') == epg_path + +@plugin.route() +def setup(**kwargs): + if _setup(): + gui.refresh() + +def _setup(): + addon = get_addon(IPTV_SIMPLE_ID, required=True, install=True) + + with gui.progress(_.SETTING_UP_IPTV) as progress: + kodi_rpc('Addons.SetAddonEnabled', {'addonid': IPTV_SIMPLE_ID, 'enabled': False}) + + output_dir = xbmc.translatePath(settings.get('output_dir', '').strip() or ADDON_PROFILE) + playlist_path = os.path.join(output_dir, PLAYLIST_FILE_NAME) + epg_path = os.path.join(output_dir, EPG_FILE_NAME) + + if not os.path.exists(playlist_path): + with open(playlist_path, 'w') as f: + f.write('''#EXTM3U +#EXTINF:-1 tvg-id="iptv_merge" tvg-chno="1000" tvg-logo="{}",{} +{}'''.format(ADDON_ICON, 'IPTV Merge: Click me to run a merge!', plugin.url_for(merge))) + + if not os.path.exists(epg_path): + with open(epg_path, 'w') as f: + f.write('''''') + + ## IMPORT ANY CURRENT SOURCES ## + cur_epg_url = addon.getSetting('epgUrl') + cur_epg_path = addon.getSetting('epgPath') + cur_epg_type = addon.getSetting('epgPathType') + + if cur_epg_path != epg_path and os.path.exists(xbmc.translatePath(cur_epg_path)): + epg = EPG(source_type=EPG.TYPE_FILE, path=cur_epg_path, enabled=cur_epg_type == '0') + epg.auto_archive_type() + try: epg.save() + except: pass + + if cur_epg_url: + epg = EPG(source_type=EPG.TYPE_URL, path=cur_epg_url, enabled=cur_epg_type == '1') + epg.auto_archive_type() + try: epg.save() + except: pass + + cur_m3u_url = addon.getSetting('m3uUrl') + cur_m3u_path = addon.getSetting('m3uPath') + cur_m3u_type = addon.getSetting('m3uPathType') + start_chno = int(addon.getSetting('startNum') or 1) + #user_agent = addon.getSetting('userAgent') + + if cur_m3u_path != playlist_path and os.path.exists(xbmc.translatePath(cur_m3u_path)): + playlist = Playlist(source_type=Playlist.TYPE_FILE, path=cur_m3u_path, enabled=cur_m3u_type == '0') + playlist.auto_archive_type() + if start_chno != 1: + playlist.use_start_chno = True + playlist.start_chno = start_chno + + try: playlist.save() + except: pass + + if cur_m3u_url: + playlist = Playlist(source_type=Playlist.TYPE_URL, path=cur_m3u_url, enabled=cur_m3u_type == '1') + playlist.auto_archive_type() + if start_chno != 1: + playlist.use_start_chno = True + playlist.start_chno = start_chno + + try: playlist.save() + except: pass + ##### + + addon.setSetting('epgPath', epg_path) + addon.setSetting('m3uPath', playlist_path) + addon.setSetting('epgUrl', '') + addon.setSetting('m3uUrl', '') + addon.setSetting('m3uPathType', '0') + addon.setSetting('epgPathType', '0') + + monitor = xbmc.Monitor() + + progress.update(30) + + monitor.waitForAbort(2) + kodi_rpc('Addons.SetAddonEnabled', {'addonid': IPTV_SIMPLE_ID, 'enabled': True}) + + progress.update(60) + + monitor.waitForAbort(2) + + progress.update(100) + + set_kodi_setting('epg.futuredaystodisplay', 7) + # set_kodi_setting('epg.ignoredbforclient', True) + set_kodi_setting('pvrmanager.syncchannelgroups', True) + set_kodi_setting('pvrmanager.preselectplayingchannel', True) + set_kodi_setting('pvrmanager.backendchannelorder', True) + set_kodi_setting('pvrmanager.usebackendchannelnumbers', True) + + gui.ok(_.SETUP_IPTV_COMPLETE) + + return True + +@plugin.route() +def merge(**kwargs): + if get_kodi_string('_iptv_merge_force_run'): + raise PluginError(_.MERGE_IN_PROGRESS) + else: + set_kodi_string('_iptv_merge_force_run', '1') + +@plugin.route() +def proxy_merge(type='all', **kwargs): + merge = Merger() + + if type == 'playlist': + path = merge.playlists() + + elif type == 'epg': + path = merge.epgs() + + elif type == 'all': + merge.playlists() + merge.epgs() + path = merge.output_path + + return plugin.Item(path=path) + +@plugin.route() +@plugin.merge() +def service_merge(forced=0, **kwargs): + merge = Merger(forced=int(forced)) + merge.playlists() + merge.epgs() + +@plugin.route() +def setup_addon(addon_id, **kwargs): + if not iptv_is_setup() and not _setup(): + return + + addon, data = merge_info(addon_id) + + if METHOD_PLAYLIST in data: + playlist, created = Playlist.get_or_create(path=addon_id, defaults={'source_type': Playlist.TYPE_ADDON, 'enabled': True}) + if not playlist.enabled: + playlist.enabled = True + playlist.save() + + if METHOD_EPG in data: + epg, created = EPG.get_or_create(path=addon_id, defaults={'source_type': EPG.TYPE_ADDON, 'enabled': True}) + if not epg.enabled: + epg.enabled = True + epg.save() + + set_kodi_string('_iptv_merge_force_run', '1') \ No newline at end of file diff --git a/plugin.program.iptv.merge/resources/lib/service.py b/plugin.program.iptv.merge/resources/lib/service.py new file mode 100644 index 00000000..b2fc81c9 --- /dev/null +++ b/plugin.program.iptv.merge/resources/lib/service.py @@ -0,0 +1,89 @@ +import time +import sqlite3 + +from kodi_six import xbmc, xbmcvfs, xbmcaddon +from six.moves.urllib.parse import unquote + +from slyguy import router, settings, userdata, gui +from slyguy.constants import ADDON_DEV, KODI_VERSION +from slyguy.util import get_kodi_string, set_kodi_string, kodi_rpc, kodi_db +from slyguy.log import log + +from .constants import * + +def _clean_tables(db_name, tables): + if not tables or not db_name: + return + + db_path = kodi_db(db_name) + if not db_path: + return + + conn = sqlite3.connect(db_path, isolation_level=None) + try: + c = conn.cursor() + + for table in tables: + c.execute("DELETE FROM {};".format(table)) + + c.execute("VACUUM;") + conn.commit() + except: + raise + else: + log.debug('DB Cleaned: {}'.format(db_path)) + finally: + conn.close() + +def start(): + monitor = xbmc.Monitor() + restart_queued = False + + boot_merge = settings.getBool('boot_merge', False) + set_kodi_string('_iptv_merge_force_run') + + while not monitor.waitForAbort(1): + forced = ADDON_DEV or get_kodi_string('_iptv_merge_force_run') or 0 + + if forced or boot_merge or (settings.getBool('auto_merge', True) and time.time() - userdata.get('last_run', 0) > settings.getInt('reload_time_hours', 12) * 3600): + set_kodi_string('_iptv_merge_force_run', '1') + + url = router.url_for('service_merge', forced=forced) + dirs, files = xbmcvfs.listdir(url) + msg = unquote(files[0]) + + if msg == 'ok': + restart_queued = True + + userdata.set('last_run', int(time.time())) + set_kodi_string('_iptv_merge_force_run') + + if restart_queued and settings.getBool('restart_pvr', False): + if forced: progress = gui.progressbg(heading='Reloading IPTV Simple Client') + + if forced or (not xbmc.getCondVisibility('Pvr.IsPlayingTv') and not xbmc.getCondVisibility('Pvr.IsPlayingRadio')): + restart_queued = False + + kodi_rpc('Addons.SetAddonEnabled', {'addonid': IPTV_SIMPLE_ID, 'enabled': False}) + + wait_delay = 4 + for i in range(wait_delay): + if monitor.waitForAbort(1): + break + if forced: progress.update((i+1)*int(100/wait_delay)) + + if settings.getBool('clean_dbs', True): + try: _clean_tables('tv', ['channelgroups', 'channels', 'map_channelgroups_channels']) + except: pass + try: _clean_tables('epg', ['epg', 'epgtags', 'lastepgscan']) + except: pass + + kodi_rpc('Addons.SetAddonEnabled', {'addonid': IPTV_SIMPLE_ID, 'enabled': True}) + + if forced: + progress.update(100) + progress.close() + + boot_merge = False + if ADDON_DEV: + break \ No newline at end of file diff --git a/plugin.program.iptv.merge/resources/settings.xml b/plugin.program.iptv.merge/resources/settings.xml new file mode 100644 index 00000000..0f3d0170 --- /dev/null +++ b/plugin.program.iptv.merge/resources/settings.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.program.iptv.merge/service.py b/plugin.program.iptv.merge/service.py new file mode 100644 index 00000000..497ebee0 --- /dev/null +++ b/plugin.program.iptv.merge/service.py @@ -0,0 +1,3 @@ +from resources.lib import service + +service.start() \ No newline at end of file diff --git a/plugin.program.noobs.companion/addon.xml b/plugin.program.noobs.companion/addon.xml new file mode 100644 index 00000000..5e74465f --- /dev/null +++ b/plugin.program.noobs.companion/addon.xml @@ -0,0 +1,21 @@ + + + + + + + executable + + + + Allows easy booting between other installed NOOBS / PINN systems (Raspberry Pi Only) + + + + Fix Recalbox bootback + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.program.noobs.companion/default.py b/plugin.program.noobs.companion/default.py new file mode 100644 index 00000000..419ac738 --- /dev/null +++ b/plugin.program.noobs.companion/default.py @@ -0,0 +1,175 @@ +import os +import sys +import time +import traceback + +from six.moves.urllib_parse import parse_qsl +from kodi_six import xbmc, xbmcplugin, xbmcgui + +from resources.lib import util +from resources.lib import config + +dialog = xbmcgui.Dialog() + +def error(message): + dialog.ok('ERROR!', str(message)) + +def boot_system(system_key): + system = util.get_system(system_key) + if not system: + dialog.notification(config.__addonname__, 'Could not find system: {0}'.format(system_key), os.path.join(config.__addonpath__, 'icon.png'), 3000) + return + + name = system.get('name', '') + icon = system.get('icon', xbmcgui.NOTIFICATION_INFO) + + dialog.notification(name, 'Booting....', icon, 2000) + time.sleep(1) + util.partition_boot(system.get('partitions')[0]) + +def install_system(system_key): + from resources.lib import install + + system = util.get_system(system_key) + if not system: + return + + name = system.get('name', '') + icon = system.get('icon', xbmcgui.NOTIFICATION_INFO) + + function = getattr(install, util.get_system_info(system_key).get('boot-back', ''), None) + if not function: + dialog.notification(name, 'Currently not Boot-Back installable.', icon, 5000) + return + + function(system.get('partitions')) + dialog.notification(name, 'Boot-Back Installed', icon, 2000) + xbmc.executebuiltin('Container.Refresh') + +def defaultboot_system(system_key): + system = util.get_system(system_key) + if not system: + return + + name = system.get('name', '') + icon = system.get('icon', xbmcgui.NOTIFICATION_INFO) + + util.partition_defaultboot(system.get('partitions')[0]) + dialog.notification(name, 'Set to Default Boot', icon, 2000) + +def rename_system(system_key): + system = util.get_system(system_key) + if not system: + return + + kb = xbmc.Keyboard() + kb.setHeading('Rename system') + kb.setDefault(system.get('name','')) + kb.doModal() + if not kb.isConfirmed(): + return + + new_name = kb.getText() + name = system.get('name', '') + icon = system.get('icon', xbmcgui.NOTIFICATION_INFO) + + util.update_system(system_key, {'name' : new_name}) + xbmc.executebuiltin('Container.Refresh') + +def set_icon_system(system_key): + system = util.get_system(system_key) + if not system: + return + + new_icon = dialog.browseSingle(2, 'Choose a new icon', 'files') + if not new_icon: + return + + name = system.get('name', '') + icon = system.get('icon', xbmcgui.NOTIFICATION_INFO) + + util.update_system(system_key, {'icon' : new_icon}) + xbmc.executebuiltin('Container.Refresh') + +def showbootcommands(system_key): + system = util.get_system(system_key) + if not system: + return + + kodi_cmd = 'RunPlugin({}\n ?action=boot&system={})'.format(sys.argv[0], system_key) + sys_cmd = util.get_boot_cmd(system.get('partitions')[0]) + + dialog.textviewer('Boot Commands', 'Kodi:\n[B]{}[/B]\n\nShell:\n[B]{}[/B]'.format(kodi_cmd, sys_cmd)) + +def clear_data(): + if dialog.yesno('Clear Data?', 'This will delete the current saved data for this addon.\nYou will lose any custom names / icons you have set.', ): + util.delete_data() + dialog.notification(config.__addonname__, 'Data cleared', os.path.join(config.__addonpath__, 'icon.png'), 3000) + +def list_systems(): + systems = util.get_systems() + + for system_key in sorted(systems, key=lambda k: systems[k]['name']): + system = systems[system_key] + + listitem = xbmcgui.ListItem() + listitem.setLabel(system['name']) + listitem.setArt({'thumb': system['icon']}) + + context_items = [ + ('Rename', "RunPlugin({0}?action=rename&system={1})".format(sys.argv[0], system_key)), + ('Set Icon', "RunPlugin({0}?action=set_icon&system={1})".format(sys.argv[0], system_key)), + ('Set to Default Boot', "RunPlugin({0}?action=defaultboot&system={1})".format(sys.argv[0], system_key)), + ('Show Boot Commands', "RunPlugin({0}?action=showbootcommands&system={1})".format(sys.argv[0], system_key)), + ] + + if util.get_system_info(system_key).get('boot-back', None): + context_items.extend(( + ('Install Boot-Back', "RunPlugin({0}?action=install&system={1})".format(sys.argv[0], system_key)), + )) + + listitem.addContextMenuItems(context_items) + + action = "{0}?action=boot&system={1}".format(sys.argv[0], system_key) + xbmcplugin.addDirectoryItem(int(sys.argv[1]), action, listitem, isFolder=False) + + xbmcplugin.endOfDirectory(int(sys.argv[1]), cacheToDisc=False) + +try: + ## Doesnt work on Pi4: https://forum.kodi.tv/showthread.php?tid=346077&pid=2875293 + # if not xbmc.getCondVisibility('System.Platform.Linux.RaspberryPi'): + # dialog.ok("Not Supported", 'This addon only works on the Raspberry Pi range of boards.') + # sys.exit(0) + + if config.__system__ == config.NOT_SUPPORTED: + dialog.ok("Not Supported", 'The supported systems are:\nLibreELEC, OpenELEC, OSMC & Xbian') + sys.exit(0) + + try: + util.init() + except: + dialog.ok("ERROR", 'Failed to initialise systems.\nPlease make sure you are using NOOBS or PINN.') + sys.exit(0) + + params = dict(parse_qsl(sys.argv[2][1:])) + action = params.get('action') + + if action == 'boot': + boot_system(params.get('system')) + elif action == 'install': + install_system(params.get('system')) + elif action == 'defaultboot': + defaultboot_system(params.get('system')) + elif action == 'showbootcommands': + showbootcommands(params.get('system')) + elif action == 'rename': + rename_system(params.get('system')) + elif action == 'set_icon': + set_icon_system(params.get('system')) + elif action == 'clear': + clear_data() + else: + list_systems() +except Exception as e: + traceback.print_exc() + error(e) \ No newline at end of file diff --git a/plugin.program.noobs.companion/icon.png b/plugin.program.noobs.companion/icon.png new file mode 100644 index 00000000..ddbcf2ad Binary files /dev/null and b/plugin.program.noobs.companion/icon.png differ diff --git a/plugin.program.noobs.companion/resources/__init__.py b/plugin.program.noobs.companion/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.program.noobs.companion/resources/files/custom.sh b/plugin.program.noobs.companion/resources/files/custom.sh new file mode 100644 index 00000000..b2753f2f --- /dev/null +++ b/plugin.program.noobs.companion/resources/files/custom.sh @@ -0,0 +1,2 @@ +#!/bin/bash +/bin/bash /media/SHARE/system/custom.sh \ No newline at end of file diff --git a/plugin.program.noobs.companion/resources/files/kodi.png b/plugin.program.noobs.companion/resources/files/kodi.png new file mode 100644 index 00000000..a9502117 Binary files /dev/null and b/plugin.program.noobs.companion/resources/files/kodi.png differ diff --git a/plugin.program.noobs.companion/resources/files/part_reboot b/plugin.program.noobs.companion/resources/files/part_reboot new file mode 100644 index 00000000..19a43fa4 Binary files /dev/null and b/plugin.program.noobs.companion/resources/files/part_reboot differ diff --git a/plugin.program.noobs.companion/resources/files/part_reboot.c b/plugin.program.noobs.companion/resources/files/part_reboot.c new file mode 100644 index 00000000..4c8e6022 --- /dev/null +++ b/plugin.program.noobs.companion/resources/files/part_reboot.c @@ -0,0 +1,25 @@ +#include +#include +#include +#include + +int main(int argc, char *argv[]) { + char *param = NULL; + int cmd; + + if(argc == 2) { + param = argv[1]; + cmd = LINUX_REBOOT_CMD_RESTART2; + } + else { + cmd = LINUX_REBOOT_CMD_RESTART; + } + + sync(); + system("/etc/init.d/rcK || /etc/init.d/rc 0"); + + sync(); + syscall(SYS_reboot, LINUX_REBOOT_MAGIC1, LINUX_REBOOT_MAGIC2, cmd, param); + + return 0; +} diff --git a/plugin.program.noobs.companion/resources/language/resource.language.en_gb/strings.po b/plugin.program.noobs.companion/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..7d0f7cc4 --- /dev/null +++ b/plugin.program.noobs.companion/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,10 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Clear Data" +msgstr "" + +msgctxt "#32036" +msgid "Advanced" +msgstr "" \ No newline at end of file diff --git a/plugin.program.noobs.companion/resources/lib/__init__.py b/plugin.program.noobs.companion/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.program.noobs.companion/resources/lib/config.py b/plugin.program.noobs.companion/resources/lib/config.py new file mode 100644 index 00000000..5ac2dc08 --- /dev/null +++ b/plugin.program.noobs.companion/resources/lib/config.py @@ -0,0 +1,107 @@ +import os +import re +import json +import codecs + +from kodi_six import xbmc, xbmcaddon + +__addon__ = xbmcaddon.Addon() +__addonid__ = __addon__.getAddonInfo('id') +__addonversion__ = __addon__.getAddonInfo('version') +__addonname__ = __addon__.getAddonInfo('name') +__language__ = __addon__.getLocalizedString +__addonpath__ = xbmc.translatePath(__addon__.getAddonInfo('path')) +__datapath__ = xbmc.translatePath(__addon__.getAddonInfo('profile')) +__files_path__ = os.path.join(__addonpath__, 'resources', 'files') +__data_file__ = os.path.join(__datapath__, 'data.json') +__partition_pattern = re.compile(r'^(/dev/.*[^0-9])([0-9]+)$') +__recovery_part__ = 1 +__settings_part__ = 5 + +LIBREELEC = 'LibreELEC' +OSMC = 'OSMC' +XBIAN = 'XBIAN' +NOT_SUPPORTED = 'Not Supported' + +if os.path.exists('/storage/.kodi'): + __system__ = LIBREELEC + __boot__ = '/flash' + __reboot__ = "reboot {0}" + __cmd__ = '{0}' +elif os.path.exists('/home/osmc'): + __system__ = OSMC + __boot__ = '/boot' + __reboot__ = "reboot {0}" + __cmd__ = 'sudo su -c "{0}"' +elif os.path.exists('/home/xbian'): + __system__ = XBIAN + __boot__ = "/boot" + __reboot__ = "'{1}' {0}" + __cmd__ = 'echo raspberry | sudo -S su -c "{0}"' +else: + __system__ = NOT_SUPPORTED + +if not os.path.exists(__datapath__): + os.mkdir(__datapath__) + +try: + with codecs.open(__data_file__, 'r', encoding='utf8') as f: + DATA = json.load(f) +except: + DATA = {'user':{}, 'system':{}} + +def save_data(): + with codecs.open(__data_file__, 'w', encoding='utf8') as f: + f.write(json.dumps(DATA, ensure_ascii=False)) + +__systems__ = [ + # https://downloads.raspberrypi.org/os_list_v3.json # + {'pattern': 'LibreELEC', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/libreelec.png'}, + {'pattern': 'OSMC', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/osmc.png'}, + {'pattern': 'Lakka', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/lakka.png', 'boot-back': 'lakka'}, + {'pattern': 'RaspbianLite', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/raspbian.png'}, + {'pattern': 'Raspbian', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/raspbian.png', 'boot-back': 'raspbian'}, + {'pattern': 'Screenly', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/screenlyose.png'}, + {'pattern': 'RISC', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/riscos.png'}, + {'pattern': 'Windows', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/windows10iotcore.png'}, + {'pattern': 'TLXOS', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/tlxos.png'}, + + # https://raw.githubusercontent.com/procount/pinn-os/master/os/os_list_v3.json # + {'pattern': 'AIYprojects', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/aiyprojects.png'}, + {'pattern': 'CStemBian', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/cstembian.png'}, + {'pattern': 'PiTop', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/pitop.png'}, + {'pattern': 'solydx', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/solydx.png'}, + {'pattern': 'ubuntuMate1604LTS', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/ubuntu-mate.png'}, + {'pattern': 'openelec', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/openelec.png'}, + {'pattern': 'XBian', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/xbian.png'}, + {'pattern': 'Retropie', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/retropie.png', 'boot-back': 'retropie'}, + {'pattern': 'kali', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/kali.png'}, + {'pattern': 'rtandroid', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/rtandroid.png'}, + {'pattern': 'lede2', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/lede2.png'}, + {'pattern': 'Arch', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/arch.png'}, + {'pattern': 'void', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/void.png'}, + {'pattern': 'gentoo', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/gentoo.png'}, + {'pattern': 'hypriotos', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/hypriot.png'}, + {'pattern': 'raspberry-vi', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/raspberry-vi.png'}, + {'pattern': 'picoreplayer', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/picoreplayer.png'}, + {'pattern': 'quirky', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/quirky.png'}, + {'pattern': 'lineage', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/lineage.png'}, + + # https://raw.githubusercontent.com/matthuisman/pinn-os/master/os/os_list_v3.json # + {'pattern': 'batocera', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/batocera.png', 'boot-back': 'batocera'}, + {'pattern': 'Kano_OS', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/kano_os.png'}, + {'pattern': 'RasPlex', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/rasplex.png'}, + {'pattern': 'PiMusicBox', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/pimusicbox.png'}, + {'pattern': 'RetroX', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/retrox.png'}, + {'pattern': 'FlintOS', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/flintos.png'}, + {'pattern': 'FedBerry', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/fedberry.png'}, + {'pattern': 'Amibian', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/amibian.png'}, + {'pattern': 'Gladys', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/gladys.png'}, + {'pattern': 'DietPi', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/dietpi.png'}, + {'pattern': 'resinOS', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/resinos.png'}, + {'pattern': 'recalbox', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/recalboxos.png', 'boot-back': 'recalbox'}, + + # Built-in + {'pattern': 'NOOBS', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/noobs.png'}, + {'pattern': 'PINN', 'icon': 'https://k.slyguy.xyz/.images/noobs-companion/pinn.png'}, +] \ No newline at end of file diff --git a/plugin.program.noobs.companion/resources/lib/install.py b/plugin.program.noobs.companion/resources/lib/install.py new file mode 100644 index 00000000..627f93db --- /dev/null +++ b/plugin.program.noobs.companion/resources/lib/install.py @@ -0,0 +1,159 @@ +import os +import shutil +import re +from xml.dom.minidom import parseString + +from . import util +from . import config + +def retropie(partitions): + try: + dst = util.partition_mount(partitions[1], 'rw') + + path = os.path.join(dst, 'home', 'pi', 'RetroPie', 'roms', 'kodi') + util.cmd("mkdir -p '{0}'".format(path)) + + content = """#!/bin/sh +sudo su -c '(echo {0} > /sys/module/bcm270?/parameters/reboot_part && reboot) || reboot {0}'""".format(util.my_partition_number()) + + util.write_file(os.path.join(path, 'kodi.sh'), content) + util.cmd("chmod +x '{0}' && chown -R 1000:1000 '{1}'".format(os.path.join(path, 'kodi.sh'), path)) + + with open(os.path.join(dst, 'etc', 'emulationstation', 'es_systems.cfg')) as f: + doc = parseString(f.read()) + + system = parseString(""" + Kodi + kodi + /home/pi/RetroPie/roms/kodi + .sh .SH + bash %ROM% + kodi + kodi +""").documentElement + + doc.childNodes[0].appendChild(system) + + config_path = os.path.join(dst, 'opt', 'retropie', 'configs', 'all', 'emulationstation', 'es_systems.cfg') + util.write_file(config_path, doc.toxml()) + except: + raise + finally: + util.partition_umount(partitions[1]) + +def raspbian(partitions): + try: + dst = util.partition_mount(partitions[1], 'rw') + + path = os.path.join(dst, 'home', 'pi', '.' + config.__addonid__) + util.cmd("mkdir -p '{0}'".format(path)) + + content = """#!/bin/sh +sudo su -c '(echo {0} > /sys/module/bcm270?/parameters/reboot_part && reboot) || reboot {0}'""".format(util.my_partition_number()) + + util.write_file(os.path.join(path, 'launcher.sh'), content) + + cmd = "chmod +x '{0}'".format(os.path.join(path, 'launcher.sh')) + cmd += " && cp -rf '{0}' '{1}'".format(os.path.join(config.__files_path__, 'kodi.png'), os.path.join(path, 'icon.png')) + cmd += " && chown -R 1000:1000 '{0}'".format(path) + + path = os.path.join(dst, 'home', 'pi', 'Desktop') + cmd += " && mkdir -p '{0}'".format(path) + + util.cmd(cmd) + + content = """[Desktop Entry] +Name=KODI +Comment=KODI +Icon=/home/pi/{0}/icon.png +Exec=/bin/sh /home/pi/{0}/launcher.sh +Type=Application +Encoding=UTF-8 +Terminal=false +Categories=None;""".format('.' + config.__addonid__) + + desktop_file = os.path.join(path, config.__addonid__ + '.desktop') + util.write_file(desktop_file, content) + util.cmd("chown -R 1000:1000 '{0}'".format(path)) + except: + raise + finally: + util.partition_umount(partitions[1]) + +def batocera(partitions): + ## Install boot-back ## + try: + dst = util.partition_mount(partitions[1], 'rw') + + content = """#!/bin/sh +mount -o remount,rw / +cat < /usr/bin/batocera-kodilauncher +#!/bin/bash +(echo {0} > /sys/module/bcm270?/parameters/reboot_part && reboot) || reboot {0} || /media/SHARE/system/part_reboot {0} +sleep 5 +EOT +mount -o remount,ro /""".format(util.my_partition_number()) + + file_path = os.path.join(dst, 'system', 'custom.sh') + util.cmd("mkdir -p '{0}'".format(os.path.dirname(file_path))) + util.write_file(file_path, content) + + cmd = "chmod +x '{0}'".format(file_path) + cmd += " && cp -rf '{0}' '{1}' && chmod +x '{1}'".format(config.DATA['system']['part_reboot'], os.path.join(dst, 'system', 'part_reboot')) + cmd += " && (sed '{0}' -i -e 's|kodi.atstartup.*|kodi.atstartup=0|' || true)".format(os.path.join(dst, 'system', 'batocera.conf')) + util.cmd(cmd) + except: raise + finally: util.partition_umount(partitions[1]) + + ## Fix cmdline.txt ## + try: + dst = util.partition_mount(partitions[0], 'rw') + file_path = os.path.join(dst, 'cmdline.txt') + util.cmd("sed '{0}' -i -e 's|dev=[^ ]*|dev={1}|'".format(file_path, partitions[0])) + except: pass + finally: util.partition_umount(partitions[0]) + +def recalbox(partitions): + ## Install boot-back ## + try: + dst = util.partition_mount(partitions[2], 'rw') + + content = """#!/bin/bash +(echo {0} > /sys/module/bcm270?/parameters/reboot_part && reboot) || reboot {0} || /recalbox/scripts/part_reboot {0} +sleep 5""".format(util.my_partition_number()) + + file_path = os.path.join(dst, 'upper', 'recalbox', 'scripts', 'kodilauncher.sh') + util.cmd("mkdir -p '{0}'".format(os.path.dirname(file_path))) + util.write_file(file_path, content) + + cmd = "chmod +x '{0}'".format(file_path) + cmd += " && cp -rf '{0}' '{1}' && chmod +x '{1}'".format(config.DATA['system']['part_reboot'], os.path.join(dst, 'upper', 'recalbox', 'scripts', 'part_reboot')) + util.cmd(cmd) + except: + raise + finally: + util.partition_umount(partitions[2]) + + ## Remove kodi at startup config ## + try: + dst = util.partition_mount(partitions[1], 'rw') + util.cmd("(sed '{0}' -i -e 's|kodi.atstartup.*|kodi.atstartup=0|' || true)".format(os.path.join(dst, 'system', 'recalbox.conf'))) + except: pass + finally: util.partition_umount(partitions[1]) + +def lakka(partitions): + try: + dst = util.partition_mount(partitions[1], 'rw') + + content = """[Service] +Restart=on-failure +ExecStopPost=/bin/bash -c '[ $SERVICE_RESULT = "success" ] && reboot {0}' +""".format(util.my_partition_number()) + + file_path = os.path.join(dst, '.config', 'system.d', 'retroarch.service.d', '10-kodi-boot-back.conf') + util.cmd("mkdir -p '{0}'".format(os.path.dirname(file_path))) + util.write_file(file_path, content) + except: + raise + finally: + util.partition_umount(partitions[1]) diff --git a/plugin.program.noobs.companion/resources/lib/util.py b/plugin.program.noobs.companion/resources/lib/util.py new file mode 100644 index 00000000..cc63e44d --- /dev/null +++ b/plugin.program.noobs.companion/resources/lib/util.py @@ -0,0 +1,226 @@ +import os +import sys +import re +import shutil +import json +import time +import subprocess +import traceback + +from kodi_six import xbmc +from six.moves.configparser import ConfigParser +from six import StringIO + +from . import config + +def cmd(cmd, wait=True): + if 'su -c' in config.__cmd__: + cmd = cmd.replace('$', '\$') + + cmd = config.__cmd__.format(cmd) + print(cmd) + + if not wait: + try: + return subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=True) + except: + traceback.print_exc() + raise Exception('Cmd failed: {0}'.format(cmd)) + else: + try: + return subprocess.check_output(cmd, shell=True).strip() + except: + traceback.print_exc() + raise Exception('Cmd failed: {0}'.format(cmd)) + +def my_partition_number(): + return partition_number(config.DATA['system']['my_partition']) + +def write_file(file_path, content): + temp_file = os.path.join(config.__datapath__, os.path.basename(file_path)) + with open(temp_file, 'w') as f: + f.write(content) + + cmd("cp -rf '{0}' '{1}'".format(temp_file, file_path)) + cmd("rm -f '{0}'".format(temp_file)) + if not os.path.exists(file_path): + raise Exception("Failed to copy file to %s" % file_path) + +def partition_mount(partition, mode='ro'): + mount_info = get_mounts().get(partition) + if mount_info: + if mode == 'ro' or mode == mount_info['mode']: + return mount_info['mount'] + else: + partition_umount(partition) + + dst = os.path.join(config.__datapath__, os.path.basename(partition)) + try: + cmd("mkdir -p '{2}' && mount -t auto -o {0} {1} '{2}'".format(mode, partition, dst)) + except: + raise Exception("Failed to mount: {0}".format(partition)) + + return dst + +def partition_umount(partition): + dst = os.path.join(config.__datapath__, os.path.basename(partition)) + cmd("( umount '{0}' || true ) && ( rmdir '{0}' || true )".format(dst)) + +def get_boot_cmd(partition): + part = partition_number(partition) + return config.__reboot__.format(part, config.DATA['system']['part_reboot']) + +def partition_boot(partition): + cmd(get_boot_cmd(partition), wait=False) + +def partition_number(partition): + return int(config.__partition_pattern.match(partition).group(2)) + +def partition_defaultboot(partition): + try: + settings_path = partition_mount(config.DATA['system']['settings_partition'], 'rw') + conf_path = os.path.join(settings_path, 'noobs.conf') + + noobs_conf = ConfigParser() + noobs_conf.read(conf_path) + + section = 'General' + + if not noobs_conf.has_section(section): + noobs_conf.add_section(section) + + if partition == config.DATA['system']['recovery_partition']: + noobs_conf.remove_option(section, 'default_partition_to_boot') + noobs_conf.remove_option(section, 'sticky_boot') + else: + noobs_conf.set(section, 'default_partition_to_boot', str(partition_number(partition))) + noobs_conf.set(section, 'sticky_boot', str(partition_number(partition))) + + output = StringIO() + noobs_conf.write(output) + write_file(conf_path, output.getvalue()) + except: + raise + finally: + partition_umount(config.DATA['system']['settings_partition']) + +def get_systems(): + systems = config.DATA['system'].get('systems', {}) + for key in systems: + systems[key].update(config.DATA['user'].get(key, {})) + return systems + +def get_system(system_key): + systems = get_systems() + for key in systems: + if key == system_key: + return systems[key] + return None + +def update_system(system_key, data): + config.DATA['user'].setdefault(system_key, data) + config.DATA['user'][system_key].update(data) + config.save_data() + +def delete_data(): + os.remove(config.__data_file__) + +def get_system_info(system_key): + for info in config.__systems__: + if re.search(info['pattern'], system_key, re.IGNORECASE): + return info + return {} + +def get_system_key(name): + return name.replace(' ','').lower().strip() + +def get_mounts(): + mounts = {} + with open('/proc/mounts','r') as f: + for line in f.readlines(): + mount = line.split() + if config.__partition_pattern.match(mount[0]) and '/dev/loop' not in mount[0]: + mounts[mount[0]] = {'mount':mount[1], 'mode':mount[3].split(',')[0]} + return mounts + +def init(): + if config.DATA['system'].get('version', '') == config.__addonversion__: + return + + config.DATA['system'] = {'version': config.__addonversion__} + + mounts = get_mounts() + for mount in mounts: + if mounts[mount]['mount'] == config.__boot__: + config.DATA['system']['my_partition'] = mount + + boot_device = config.__partition_pattern.match(config.DATA['system']['my_partition']).group(1) + config.DATA['system']['recovery_partition'] = boot_device + str(config.__recovery_part__) + config.DATA['system']['settings_partition'] = boot_device + str(config.__settings_part__) + config.DATA['system']['part_reboot'] = os.path.join(config.__files_path__, 'part_reboot') + cmd("chmod +x '{0}'".format(config.DATA['system']['part_reboot'])) + + _build_systems() + config.save_data() + +def _build_systems(): + config.DATA['system']['systems'] = {} + + try: + recovery_path = partition_mount(config.DATA['system']['recovery_partition']) + settings_path = partition_mount(config.DATA['system']['settings_partition']) + + with open(os.path.join(settings_path, 'installed_os.json')) as f: + raw_systems = json.loads(f.read()) + + sys_name = 'NOOBS' + try: + with open(os.path.join(recovery_path, 'recovery.cmdline')) as f: + data = f.read() + + if 'alt_image_source' in data or 'repo_list' in data: + sys_name = 'PINN' + except: + pass + + raw_systems.append({ + 'description' : 'An easy Operating System install manager for the Raspberry Pi', + 'bootable' : True, + 'partitions' : [config.DATA['system']['recovery_partition']], + 'name' : sys_name, + }) + + for system in raw_systems: + if not system['bootable'] or not system['partitions']: + continue + + system_key = get_system_key(system['name']) + system_info = get_system_info(system_key) + + icon_path = system_info.get('icon', None) + if not icon_path: + noobs_path = system.get('icon','').replace('/mnt', recovery_path).replace('/settings', settings_path) + if os.path.isfile(noobs_path): + try: + icon_path = os.path.join(config.__datapath__, system_key + '.png') + shutil.copy(noobs_path, icon_path) + except: + icon_path = None + + partitions = [] + for partition in system['partitions']: + if partition.startswith('PARTUUID'): + path = '/dev/disk/by-partuuid/{}'.format(partition.split('=')[1]) + partition = os.path.realpath(path) + + partitions.append(partition) + + system['partitions'] = partitions + system['icon'] = icon_path + config.DATA['system']['systems'][system_key] = system + except: + raise + + finally: + partition_umount(config.DATA['system']['recovery_partition']) + partition_umount(config.DATA['system']['settings_partition']) \ No newline at end of file diff --git a/plugin.program.noobs.companion/resources/settings.xml b/plugin.program.noobs.companion/resources/settings.xml new file mode 100644 index 00000000..7c56b1a1 --- /dev/null +++ b/plugin.program.noobs.companion/resources/settings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/plugin.video.au.freeview/.iptv_merge b/plugin.video.au.freeview/.iptv_merge new file mode 100644 index 00000000..0d57299e --- /dev/null +++ b/plugin.video.au.freeview/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "plugin://$ID/?_=epg&output=$FILE" +} \ No newline at end of file diff --git a/plugin.video.au.freeview/__init__.py b/plugin.video.au.freeview/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.au.freeview/addon.xml b/plugin.video.au.freeview/addon.xml new file mode 100644 index 00000000..8f543766 --- /dev/null +++ b/plugin.video.au.freeview/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Watch all your favourite AU IPTV streams! + +You can change the region (default = Sydney) in the addon settings. + true + + + + Add bookmarks. Re-arrange menus + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.au.freeview/default.py b/plugin.video.au.freeview/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.au.freeview/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.au.freeview/fanart.jpg b/plugin.video.au.freeview/fanart.jpg new file mode 100644 index 00000000..f313d162 Binary files /dev/null and b/plugin.video.au.freeview/fanart.jpg differ diff --git a/plugin.video.au.freeview/icon.png b/plugin.video.au.freeview/icon.png new file mode 100644 index 00000000..cf5dc740 Binary files /dev/null and b/plugin.video.au.freeview/icon.png differ diff --git a/plugin.video.au.freeview/resources/__init__.py b/plugin.video.au.freeview/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.au.freeview/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.au.freeview/resources/language/resource.language.en_gb/strings.po b/plugin.video.au.freeview/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..f9ae6ad6 --- /dev/null +++ b/plugin.video.au.freeview/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,68 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Region" +msgstr "" + +msgctxt "#30001" +msgid "Sydney" +msgstr "" + +msgctxt "#30002" +msgid "Melbourne" +msgstr "" + +msgctxt "#30003" +msgid "Brisbane" +msgstr "" + +msgctxt "#30004" +msgid "Perth" +msgstr "" + +msgctxt "#30005" +msgid "Adelaide" +msgstr "" + +msgctxt "#30006" +msgid "Darwin" +msgstr "" + +msgctxt "#30007" +msgid "Hobart" +msgstr "" + +msgctxt "#30008" +msgid "Canberra" +msgstr "" + +msgctxt "#30009" +msgid "Live TV" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.au.freeview/resources/lib/__init__.py b/plugin.video.au.freeview/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.au.freeview/resources/lib/constants.py b/plugin.video.au.freeview/resources/lib/constants.py new file mode 100644 index 00000000..f1cc0d03 --- /dev/null +++ b/plugin.video.au.freeview/resources/lib/constants.py @@ -0,0 +1,3 @@ +REGIONS = ['Sydney', 'Melbourne', 'Brisbane', 'Perth', 'Adelaide', 'Darwin', 'Hobart', 'Canberra'] +M3U8_URL = 'https://i.mjh.nz/au/{region}/tv.json.gz' +EPG_URL = 'https://i.mjh.nz/au/{region}/epg.xml.gz' \ No newline at end of file diff --git a/plugin.video.au.freeview/resources/lib/language.py b/plugin.video.au.freeview/resources/lib/language.py new file mode 100644 index 00000000..ca401c27 --- /dev/null +++ b/plugin.video.au.freeview/resources/lib/language.py @@ -0,0 +1,17 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + REGION = 30000, + REGIONS = { + 'Sydney': 30001, + 'Melbourne': 30002, + 'Brisbane': 30003, + 'Perth': 30004, + 'Adelaide': 30005, + 'Darwin': 30006, + 'Hobart': 30007, + 'Canberra': 30008, + } + LIVE_TV = 30009 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.au.freeview/resources/lib/plugin.py b/plugin.video.au.freeview/resources/lib/plugin.py new file mode 100644 index 00000000..e7ce0b71 --- /dev/null +++ b/plugin.video.au.freeview/resources/lib/plugin.py @@ -0,0 +1,95 @@ +import codecs + +from slyguy import plugin, settings, inputstream +from slyguy.mem_cache import cached +from slyguy.session import Session +from slyguy.util import gzip_extract + +from .constants import M3U8_URL, REGIONS, EPG_URL +from .language import _ + +session = Session() + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + folder.add_item(label=_(_.LIVE_TV, _bold=True), path=plugin.url_for(live_tv)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def live_tv(**kwargs): + region = get_region() + channels = get_channels(region) + + folder = plugin.Folder(_(_.REGIONS[region])) + + for slug in sorted(channels, key=lambda k: (channels[k].get('network', ''), channels[k].get('name', ''))): + channel = channels[slug] + + folder.add_item( + label = channel['name'], + path = plugin.url_for(play, slug=slug, _is_live=True), + info = {'plot': channel.get('description')}, + video = channel.get('video', {}), + audio = channel.get('audio', {}), + art = {'thumb': channel.get('logo')}, + playable = True, + ) + + return folder + +@plugin.route() +def play(slug, **kwargs): + region = get_region() + channel = get_channels(region)[slug] + url = session.head(channel['mjh_master'], allow_redirects=False).headers.get('location', '') + + item = plugin.Item( + path = url or channel['mjh_master'], + headers = channel['headers'], + info = {'plot': channel.get('description')}, + video = channel.get('video', {}), + audio = channel.get('audio', {}), + art = {'thumb': channel.get('logo')}, + ) + + if channel.get('hls', False): + item.inputstream = inputstream.HLS(live=True) + + return item + +@cached(60*5) +def get_channels(region): + return session.gz_json(M3U8_URL.format(region=region)) + +def get_region(): + return REGIONS[settings.getInt('region_index')] + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + region = get_region() + channels = get_channels(region) + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for slug in sorted(channels, key=lambda k: (channels[k].get('network', ''), channels[k].get('name', ''))): + channel = channels[slug] + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{chno}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=channel.get('epg_id', slug), logo=channel.get('logo', ''), name=channel['name'], chno=channel.get('channel', ''), + path=plugin.url_for(play, slug=slug, _is_live=True))) + +@plugin.route() +@plugin.merge() +def epg(output, **kwargs): + session.chunked_dl(EPG_URL.format(region=get_region()), output) + gzip_extract(output) \ No newline at end of file diff --git a/plugin.video.au.freeview/resources/settings.xml b/plugin.video.au.freeview/resources/settings.xml new file mode 100644 index 00000000..d909ab01 --- /dev/null +++ b/plugin.video.au.freeview/resources/settings.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.beinsports.apac/.iptv_merge b/plugin.video.beinsports.apac/.iptv_merge new file mode 100644 index 00000000..0d57299e --- /dev/null +++ b/plugin.video.beinsports.apac/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "plugin://$ID/?_=epg&output=$FILE" +} \ No newline at end of file diff --git a/plugin.video.beinsports.apac/__init__.py b/plugin.video.beinsports.apac/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.beinsports.apac/addon.xml b/plugin.video.beinsports.apac/addon.xml new file mode 100644 index 00000000..b7c68222 --- /dev/null +++ b/plugin.video.beinsports.apac/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Watch your favourite sports live & on demand with beIN Sports Connect (APAC). + +Subscription required. + true + + + + Add Bookmarks + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.beinsports.apac/default.py b/plugin.video.beinsports.apac/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.beinsports.apac/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.beinsports.apac/fanart.jpg b/plugin.video.beinsports.apac/fanart.jpg new file mode 100644 index 00000000..6e01e25a Binary files /dev/null and b/plugin.video.beinsports.apac/fanart.jpg differ diff --git a/plugin.video.beinsports.apac/icon.png b/plugin.video.beinsports.apac/icon.png new file mode 100644 index 00000000..02fce005 Binary files /dev/null and b/plugin.video.beinsports.apac/icon.png differ diff --git a/plugin.video.beinsports.apac/resources/__init__.py b/plugin.video.beinsports.apac/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.beinsports.apac/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.beinsports.apac/resources/language/resource.language.en_gb/strings.po b/plugin.video.beinsports.apac/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..590f000a --- /dev/null +++ b/plugin.video.beinsports.apac/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Device Link" +msgstr "" + +msgctxt "#30004" +msgid "Email / Password" +msgstr "" + +msgctxt "#30005" +msgid "1. Login to beIN website or mobile app then go to [B]TV LOGIN[/B]\n" +"2. Enter code: [B]{code}[/B]" +msgstr "" + +msgctxt "#30006" +msgid "Login with" +msgstr "" + +msgctxt "#30007" +msgid "Unable to find video asset" +msgstr "" + +msgctxt "#30008" +msgid "Live TV" +msgstr "" + +msgctxt "#30009" +msgid "Catch Up" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" diff --git a/plugin.video.beinsports.apac/resources/lib/__init__.py b/plugin.video.beinsports.apac/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.beinsports.apac/resources/lib/api.py b/plugin.video.beinsports.apac/resources/lib/api.py new file mode 100644 index 00000000..c01520a8 --- /dev/null +++ b/plugin.video.beinsports.apac/resources/lib/api.py @@ -0,0 +1,162 @@ +import json + +import arrow + +from slyguy import userdata, mem_cache +from slyguy.session import Session +from slyguy.exceptions import Error + +from .constants import * +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + + self._session = Session(HEADERS, base_url=API_BASE) + self._set_authentication() + + def _set_authentication(self): + token = userdata.get('token') + if not token: + return + + self._session.cookies.update({TOKEN_COOKIE_KEY: token}) + self.logged_in = True + + def require_token(self): + if TOKEN_COOKIE_KEY in self._session.cookies: + return + + app_version = self._session.get(APP_VERSION_URL).text.strip() + + payload = { + "ClientVersion": app_version, + "DeviceBrand": "Nvidia", + "DeviceFirmwareVersion": "27", + "DeviceId": "4jhrpoUfUr1Dxmbf", + "DeviceManufacturer": "Nvidia", + "DeviceModel": "Shield", + "DeviceName": "AndroidTv", + "IsRoot": "false", + "MacAddress": "00:00:00:00:00:00", + } + + token = self._session.post('/api/configuration/appconfig', json=payload).json()['Data']['AccessToken'] + self._session.cookies.update({TOKEN_COOKIE_KEY: token}) + + def device_code(self): + self.require_token() + return self._session.post('/api/account/generateauthcode').json()['Data'] + + def device_login(self, code): + self.require_token() + + payload = { + 'AuthCode': code, + } + + data = self._session.post('/api/account/loginwithauthcode', json=payload).json()['Data'] + if not data: + return + + token = data['User']['AccessToken'] + userdata.set('token', token) + self._set_authentication() + + return True + + def login(self, username, password): + self.require_token() + + payload = { + 'Action' : '/View/Account/SubmitLogin', + 'jsonModel': json.dumps({ + 'Username': username, + 'Password': password, + 'IsOnboarding': False, + 'IsVoucher': False, + }), + 'captcha': '', + } + + resp = self._session.post('/View/Account/SubmitLogin', json=payload) + token = resp.cookies.get(TOKEN_COOKIE_KEY) + + if not token: + raise APIError(_.LOGIN_ERROR) + + userdata.set('token', token) + self._set_authentication() + + def live_channels(self): + items = [] + + for i in range(10): + payload = { + 'Page': i, + 'PageSize': PAGESIZE, + } + + data = self._session.post('/api/broadcast/channels', json=payload).json()['Data'] + items.extend(data['Items']) + + if len(data['Items']) < PAGESIZE: + break + + return items + + def epg(self, days=3): + start = arrow.utcnow() + end = start.shift(days=days) + + payload = { + 'StartTime': start.format('YYYY-MM-DDTHH:mm:00.000') + 'Z', + 'EndTime': end.format('YYYY-MM-DDTHH:mm:00.000') + 'Z', + 'OnlyLiveEvents': False, + # 'ChannelId': 'beinsports1', + } + + return self._session.post('/api/broadcast/tvguide', json=payload).json()['Data']['Items'] + + def catch_up(self, _type=0, catalog_id='CATCHUP'): + items = [] + + for i in range(10): + payload = { + 'Page': i, + 'PageSize': PAGESIZE, + 'Type': _type, + 'CatalogId': catalog_id, + } + + data = self._session.post('/api/content/catchups', json=payload).json()['Data'] + + rows = data.get('Items', []) + if not rows: + break + + items.extend(rows) + if len(rows) < PAGESIZE: + break + + return items + + def play(self, channel_id=None, vod_id=None): + payload = { + 'ChannelId': channel_id, + 'VodId': vod_id, + } + + data = self._session.post('/api/play/play', json=payload).json()['Data'] + if not data: + raise APIError(_.NO_STREAM) + + return data + + def logout(self): + userdata.delete('token') + self.new_session() \ No newline at end of file diff --git a/plugin.video.beinsports.apac/resources/lib/constants.py b/plugin.video.beinsports.apac/resources/lib/constants.py new file mode 100644 index 00000000..df90c262 --- /dev/null +++ b/plugin.video.beinsports.apac/resources/lib/constants.py @@ -0,0 +1,9 @@ +HEADERS = { + 'User-Agent': 'okhttp/3.13.1', +} + +APP_VERSION_URL = 'https://i.mjh.nz/bein/apac.version' +TOKEN_COOKIE_KEY = 'm' +API_BASE = 'https://v3-mobileservice.apac.beiniz.biz{}' +WV_LICENSE_URL = 'https://castleblack.digiturk.com.tr/api/widevine/licenseraw?version=1' +PAGESIZE = 100 \ No newline at end of file diff --git a/plugin.video.beinsports.apac/resources/lib/language.py b/plugin.video.beinsports.apac/resources/lib/language.py new file mode 100644 index 00000000..87ab75f3 --- /dev/null +++ b/plugin.video.beinsports.apac/resources/lib/language.py @@ -0,0 +1,14 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + DEVICE_LINK = 30003 + EMAIL_PASSWORD = 30004 + DEVICE_LINK_STEPS = 30005 + LOGIN_WITH = 30006 + NO_STREAM = 30007 + LIVE_CHANNELS = 30008 + CATCH_UP = 30009 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.beinsports.apac/resources/lib/plugin.py b/plugin.video.beinsports.apac/resources/lib/plugin.py new file mode 100644 index 00000000..5d2b9249 --- /dev/null +++ b/plugin.video.beinsports.apac/resources/lib/plugin.py @@ -0,0 +1,190 @@ +import codecs +import time +import re +from xml.sax.saxutils import escape + +import arrow +from kodi_six import xbmc +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.exceptions import PluginError + +from .api import API +from .language import _ +from .constants import WV_LICENSE_URL, HEADERS + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE_CHANNELS, _bold=True), path=plugin.url_for(live_channels)) + folder.add_item(label=_(_.CATCH_UP, _bold=True), path=plugin.url_for(catch_up)) + # folder.add_item(label=_(_.MATCH_HIGHLIGHTS, _bold=True), path=plugin.url_for(catch_up, catalog_id='Match_Highlights', title=_.MATCH_HIGHLIGHTS)) + # folder.add_item(label=_(_.INTERVIEWS, _bold=True), path=plugin.url_for(catch_up, catalog_id='Interviews', title=_.INTERVIEWS)) + # folder.add_item(label=_(_.SPECIALS, _bold=True), path=plugin.url_for(catch_up, catalog_id='Specials', title=_.SPECIALS)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def login(**kwargs): + if gui.yes_no(_.LOGIN_WITH, yeslabel=_.DEVICE_LINK, nolabel=_.EMAIL_PASSWORD): + result = _device_link() + else: + result = _email_password() + + if not result: + return + + gui.refresh() + +def _email_password(): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + + return True + +def _device_link(): + start = time.time() + data = api.device_code() + monitor = xbmc.Monitor() + + #qr_code_url = data['ImagePath'] + + with gui.progress(_(_.DEVICE_LINK_STEPS, code=data['Code']), heading=_.DEVICE_LINK) as progress: + while (time.time() - start) < data['ExpireDate']: + for i in range(5): + if progress.iscanceled() or monitor.waitForAbort(1): + return + + progress.update(int(((time.time() - start) / data['ExpireDate']) * 100)) + + if api.device_login(data['Code']): + return True + +def _get_logo(url): + return re.sub('_[0-9]+X[0-9]+.', '.', url) + +@plugin.route() +def live_channels(**kwargs): + folder = plugin.Folder(_.LIVE_CHANNELS) + + for row in api.live_channels(): + folder.add_item( + label = row['Name'], + art = {'thumb': _get_logo(row['Logo'])}, + info = {'plot': row.get('Description')}, + path = plugin.url_for(play, channel_id=row['Id'], _is_live=True), + playable = True, + ) + + return folder + +@plugin.route() +def catch_up(catalog_id='CATCHUP', title=_.CATCH_UP, **kwargs): + folder = plugin.Folder(title) + + for row in api.catch_up(catalog_id=catalog_id): + program = row['Program'] + + fanart = _get_logo(program['Poster']) + + if program.get('Match'): + art = row['Program']['Match']['LeagueLogo'] + elif program.get('Competition'): + art = row['Program']['Competition']['Logo'] + else: + art = row.get('Headline') + + folder.add_item( + label = row['Name'], + art = {'fanart': fanart, 'thumb': art}, + info = {'plot': row['Program']['Description']}, + path = plugin.url_for(play, vod_id=row['Id']), + playable = True, + ) + + return folder + +@plugin.route() +@plugin.login_required() +def play(channel_id=None, vod_id=None, **kwargs): + asset = api.play(channel_id, vod_id) + + _headers = {} + _headers.update(HEADERS) + _headers.update({ + 'Authorization': asset['DrmToken'], + 'X-CB-Ticket': asset['DrmTicket'], + 'X-ErDRM-Message': asset['DrmTicket'], + }) + + return plugin.Item( + path = asset['Path'], + inputstream = inputstream.Widevine(license_key=WV_LICENSE_URL), + headers = _headers, + ) + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +@plugin.merge() +@plugin.login_required() +def playlist(output, **kwargs): + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for row in api.live_channels(): + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=row['Id'], logo=_get_logo(row['Logo']), name=row['Name'], + path=plugin.url_for(play, channel_id=row['Id'], _is_live=True))) + +@plugin.route() +@plugin.merge() +@plugin.login_required() +def epg(output, **kwargs): + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'') + + for row in api.epg(days=settings.getInt('epg_days', 3)): + channel = row['Channel'] + + f.write(u'{}'.format( + channel['Id'], escape(channel['Name']), escape(_get_logo(channel['Logo'])))) + + for program in row['EpgList']: + f.write(u'{}{}'.format( + channel['Id'], arrow.get(program['StartTime']).format('YYYYMMDDHHmmss Z'), arrow.get(program['EndTime']).format('YYYYMMDDHHmmss Z'), + escape(program['Name']), escape(program['Description']))) + + f.write(u'') \ No newline at end of file diff --git a/plugin.video.beinsports.apac/resources/settings.xml b/plugin.video.beinsports.apac/resources/settings.xml new file mode 100644 index 00000000..b9072c7a --- /dev/null +++ b/plugin.video.beinsports.apac/resources/settings.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.dstv.now/.iptv_merge b/plugin.video.dstv.now/.iptv_merge new file mode 100644 index 00000000..0d57299e --- /dev/null +++ b/plugin.video.dstv.now/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "plugin://$ID/?_=epg&output=$FILE" +} \ No newline at end of file diff --git a/plugin.video.dstv.now/__init__.py b/plugin.video.dstv.now/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.dstv.now/addon.xml b/plugin.video.dstv.now/addon.xml new file mode 100644 index 00000000..6110a375 --- /dev/null +++ b/plugin.video.dstv.now/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Watch your favourite content from DStv. + +Subscription required. + true + + + + Update login method to Device Link + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.dstv.now/default.py b/plugin.video.dstv.now/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.dstv.now/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.dstv.now/fanart.jpg b/plugin.video.dstv.now/fanart.jpg new file mode 100644 index 00000000..d7552d9a Binary files /dev/null and b/plugin.video.dstv.now/fanart.jpg differ diff --git a/plugin.video.dstv.now/icon.png b/plugin.video.dstv.now/icon.png new file mode 100644 index 00000000..3d6dd4e0 Binary files /dev/null and b/plugin.video.dstv.now/icon.png differ diff --git a/plugin.video.dstv.now/resources/__init__.py b/plugin.video.dstv.now/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.dstv.now/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.dstv.now/resources/language/resource.language.en_gb/strings.po b/plugin.video.dstv.now/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..00a6cfb9 --- /dev/null +++ b/plugin.video.dstv.now/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,95 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Device ID" +msgstr "" + +msgctxt "#30001" +msgid "Device Link" +msgstr "" + +msgctxt "#30002" +msgid "Live TV" +msgstr "" + +msgctxt "#30003" +msgid "{channel_number} - {channel_name}" +msgstr "" + +msgctxt "#30004" +msgid "TV Shows" +msgstr "" + +msgctxt "#30005" +msgid "1. Go to: [B]now.dstv.com/tv[/B]\n" +"2. Enter code: [B]{code}[/B]" +msgstr "" + +msgctxt "#30006" +msgid "{url} returned error: {code}\n" +"Make sure your entitled to this content and your IP address is allowed (not geo-blocked)\n" +"Also try logging out and logging back in" +msgstr "" + +msgctxt "#30007" +msgid "Movies" +msgstr "" + +msgctxt "#30008" +msgid "Sport" +msgstr "" + +msgctxt "#30009" +msgid "Kids" +msgstr "" + +msgctxt "#30010" +msgid "Unable to find suitable stream" +msgstr "" + +msgctxt "#30011" +msgid "Error getting data\n" +"Server Message: {msg}\n" +"Try again. If issue persists, try logout and login" +msgstr "" + +msgctxt "#30012" +msgid "Failed to refresh token\n" +"Server Message: {msg}\n" +"Try again. If issue persists, try logout and login" +msgstr "" + +msgctxt "#30013" +msgid "Could not find that channel ({id})" +msgstr "" + +msgctxt "#30017" +msgid "Next Page ({page})" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.dstv.now/resources/lib/__init__.py b/plugin.video.dstv.now/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.dstv.now/resources/lib/api.py b/plugin.video.dstv.now/resources/lib/api.py new file mode 100644 index 00000000..2f01bd9e --- /dev/null +++ b/plugin.video.dstv.now/resources/lib/api.py @@ -0,0 +1,319 @@ +import uuid +import time +import json +import threading +from contextlib import contextmanager + +import websocket +from six.moves.urllib_parse import urlparse, parse_qs + +from slyguy import userdata, settings +from slyguy.session import Session +from slyguy.log import log +from slyguy.exceptions import Error +from slyguy.util import jwt_data, get_system_arch + +from .language import _ +from .constants import * + +class APIError(Error): + pass + +class API(object): + def __init__(self): + self.new_session() + + def new_session(self): + self._logged_in = False + self._session = Session(HEADERS, base_url=API_URL, timeout=TIMEOUT) + self._set_access_token(userdata.get('access_token')) + + def _set_access_token(self, token): + if token: + self._session.headers.update({'Authorization': token}) + self._logged_in = True + + profile_id = userdata.get('profile') + if profile_id: + self._session.headers.update({'x-profile-id': profile_id}) + + @property + def logged_in(self): + return self._logged_in + + def _parse_tokens(self, access_token, id_token): + jwt = jwt_data(access_token) + + userdata.set('access_token', access_token) + userdata.set('id_token', id_token) + userdata.set('token_expires', int(time.time()) + (jwt['exp'] - jwt['iat'] - 30)) + userdata.set('country', jwt['country']) + userdata.set('package', jwt['package']) + + self._set_access_token(access_token) + + def _refresh_token(self, force=False): + if not self._logged_in: + raise APIError(_.PLUGIN_LOGIN_REQUIRED) + + expires = userdata.get('token_expires', 0) + if not force and expires > time.time(): + return + + payload = { + 'idToken': userdata.get('id_token'), + 'accessToken': userdata.get('access_token'), + } + + data = self._session.post(REFRESH_TOKEN_URL, json=payload).json() + if not data.get('accessToken') or not data.get('idToken'): + raise APIError(_(_.REFRESH_TOKEN_ERROR, msg=data.get('reason'))) + + self._parse_tokens(data['accessToken'], data['idToken']) + + def profiles(self): + return self._request_json('v6/profiles')['items'] + + def _get_auth(self): + data = self._request_json('user-manager/v5/vod-authorisation;productId={product};platformId={platform};deviceType={device_type};'.format( + product = PRODUCT_ID, platform = PLATFORM_ID, device_type = DEVICE_TYPE), type='post') + + return data + + def _request_json(self, url, type='get', timeout=30, attempts=3, refresh_token=True, **kwargs): + if refresh_token: + self._refresh_token(force=refresh_token == 'force') + + data = {} + for i in range(attempts): + if i > 0: + log.debug("Try {}/{}".format(i+1, attempts)) + + r = self._session.request(type, url, timeout=timeout, attempts=1, **kwargs) + try: data = r.json() + except: continue + + if 'errorCode' in data: + break + + if r.ok: + return data + elif not str(r.status_code).startswith('5'): + break + + if 'errorMessage' in data: + raise APIError(_(_.API_ERROR, msg=data['errorMessage'])) + elif 'reason' in data: + raise APIError(_(_.API_ERROR, msg=data['reason'])) + else: + raise APIError(_(_.REQUEST_ERROR, url=url.split(';')[0], code=r.status_code)) + + def content(self, tags, sort, category='', page=0, pagesize=24): + category = 'filter={};'.format(category) if category else '' + + data = self._request_json('now-content/v7/catalogueByPackageAndCountry;productId={product};platformId={platform};tags={tags};subscriptionPackage={package};country={country};{category}sort={sort};page={page};pageSize={pagesize}'.format( + product=PRODUCT_ID, platform=PLATFORM_ID, tags=tags, country=userdata.get('country', DEFAULT_COUNTRY), package=userdata.get('package', DEFAULT_PACKAGE), sort=sort, category=category, page=page, pagesize=pagesize, + )) + + return data + + def search(self, query): + data = self._request_json('now-content/v6/search;platformId={platform};searchTerm={query}'.format( + platform=PLATFORM_ID, query=query + )) + + return data['items'][0]['editorialItems'] + + def channels(self, events=2): + data = self._request_json('v7/epg-service/channels/events;genre={genre};platformId={platform};country={country};packageId={package};count={events};utcOffset=+00:00'.format( + genre='ALL', platform=PLATFORM_ID, country=userdata.get('country', DEFAULT_COUNTRY), package=userdata.get('package', DEFAULT_PACKAGE), events=events + )) + + return data['items'] + + def epg(self, tag, start_date, end_date=None, attempts=3): + end_date = end_date or start_date.shift(hours=24) + + for i in range(attempts): + try: + data = self._request_json('epg/v7/getEpgSchedulesByTag;channelTags={tag};startDate={start};endDate={end}'.format( + tag=tag, start=start_date.format('YYYY-MM-DDT00:00:00ZZ'), end=end_date.format('YYYY-MM-DDT00:00:00ZZ') + ), attempts=1) + except: + continue + + if len(data.get('items', 0)) > 0: + break + + return data['items'] + + def series(self, id): + data = self._request_json('now-content/v6/getCatalogue;productId={product};platformId={platform};programId={id}'.format( + product = PRODUCT_ID, platform = PLATFORM_ID, id = id + )) + + return data['items'][0]['program'] + + def get_video(self, id): + data = self._request_json('now-content/v6/getCatalogue;productId={product};platformId={platform};videoId={id}'.format( + product = PRODUCT_ID, platform = PLATFORM_ID, id = id + )) + + return data['items'][0]['video'] + + def get_channel(self, id): + for channel in self.channels(): + if channel['id'] == id: + return channel + + raise APIError(_(_.CHANNEL_NOT_FOUND, id=id)) + + def play_channel(self, id): + channel = self.get_channel(id) + + stream_url = None + for stream in channel['streams']: + if stream['streamType'] in ('MobileAlt' ,'WebAlt'): + stream_url = stream['playerUrl'] + break + + if not stream_url: + raise APIError(_.STREAM_ERROR) + + parsed = urlparse(stream_url) + content_id = parse_qs(parsed.query)['contentId'][0] + + return self.play_asset(stream_url, content_id) + + def play_video(self, id): + video = self.get_video(id) + if not video.get('videoAssets'): + raise APIError(_.STREAM_ERROR) + + stream_url = video['videoAssets'][0]['url'] + content_id = video['videoAssets'][0]['manItemId'] + + return self.play_asset(stream_url, content_id) + + def play_asset(self, stream_url, content_id): + auth = self._get_auth() + session = auth.get('irdetoSession') + + if '.isml' in stream_url: + stream_url = stream_url.replace('.isml', '.isml/.mpd') + elif '.ism' in stream_url: + stream_url = stream_url.replace('.ism', '.ism/.mpd') + + if not session: + raise APIError(data.get('errorMessage')) + + session_id = session['sessionId'] + ticket = session['ticket'] + + license_url = LICENSE_URL.format(content_id, session_id, ticket) + + return stream_url, license_url, self._session.headers + + def _device_id(self): + def _format_id(string): + try: + mac_address = uuid.getnode() + if mac_address != uuid.getnode(): + mac_address = '' + except: + mac_address = '' + + system, arch = get_system_arch() + return str(string.format(mac_address=mac_address, system=system).strip()) + + return str(uuid.uuid3(uuid.UUID(UUID_NAMESPACE), _format_id(settings.get('device_id')))) + + @contextmanager + def device_login(self): + device_id = self._device_id() + + payload = { + 'deviceId': device_id, + } + + data = self._request_json(DEVICE_REGISTER, type='post', json=payload, refresh_token=False) + code = data['userCode'] + + log.debug('Device ID: {} | Device Code: {}'.format(device_id, code)) + + login = DeviceLogin(device_id, code) + + try: + yield login + finally: + login.stop() + + if login.result: + token_data = login.token_data() + self._parse_tokens(token_data['accessToken'], token_data['idToken']) + + def logout(self): + userdata.delete('access_token') + userdata.delete('id_token') + userdata.delete('token_expires') + userdata.delete('country') + userdata.delete('package') + userdata.delete('profile') + self.new_session() + +class DeviceLogin(object): + def __init__(self, device_id, code): + self._code = code + self._device_id = device_id + self._token_data = None + self._stop = False + self._ws = None + + self._thread = threading.Thread(target=self._worker) + self._thread.daemon = True + self._thread.start() + + def token_data(self): + return self._token_data + + def is_alive(self): + return self._thread.is_alive() + + @property + def device_id(self): + return self._device_id + + @property + def code(self): + return self._code + + @property + def result(self): + return self._token_data is not None + + def stop(self): + self._stop = True + if self._ws: + self._ws.close() + self._thread.join() + + def _worker(self): + while not self._stop: + payload = { + 'event': 'pusher:subscribe', + 'data': { + 'channel': self._device_id, + } + } + + self._ws = websocket.create_connection(WEBSOCKET_URL, suppress_origin=True) + self._ws.send(json.dumps(payload)) + + while not self._stop: + try: + data = json.loads(self._ws.recv()) + if data['event'] == 'login-success': + self._token_data = json.loads(data['data']) + return + except: + break \ No newline at end of file diff --git a/plugin.video.dstv.now/resources/lib/constants.py b/plugin.video.dstv.now/resources/lib/constants.py new file mode 100644 index 00000000..e7af9e4a --- /dev/null +++ b/plugin.video.dstv.now/resources/lib/constants.py @@ -0,0 +1,25 @@ +HEADERS = { + 'User-Agent': 'okhttp/3.4.1', #mpd returns lower video quality for desktop useragents +} + +CLIENT_ID = 'dc09de02-de71-4181-9006-2754dc5d3ed3' +PRODUCT_ID = 'c53b19ce-62c0-441e-ad29-ecba2dcdb199' +PLATFORM_ID = '32faad53-5e7b-4cc0-9f33-000092e85950' +DEVICE_TYPE = 'Web' + +DEFAULT_COUNTRY = 'ZA' +DEFAULT_PACKAGE = 'PREMIUM' +EPG_URLS = { + 'ZA': 'https://i.mjh.nz/DStv/za.xml.gz', +} + +UUID_NAMESPACE = '122e1611-0232-4336-bf43-e054c8ecd0d5' +DEVICE_REGISTER = 'https://ssl.dstv.com/api/lean-back-otp/device/registration' +WEBSOCKET_URL = 'wss://ws-eu.pusher.com/app/5b1cf858986ab7d6a9d7?client=java-client&protocol=5&version=2.0.1' +REFRESH_TOKEN_URL = 'https://ssl.dstv.com/connect/connect-authtoken/v2/accesstoken/refresh?build_nr=1.0.3' +API_URL = 'https://ssl.dstv.com/api/cs-mobile/{}' +LICENSE_URL = 'https://license.dstv.com/widevine/getLicense?CrmId=afl&AccountId=afl&ContentId={}&SessionId={}&Ticket={}' +TIMEOUT = (10, 20) + +CONTENT_EXPIRY = (60*60*24) #24 hours +EPISODES_EXPIRY = (60*5) #5 Minutes \ No newline at end of file diff --git a/plugin.video.dstv.now/resources/lib/language.py b/plugin.video.dstv.now/resources/lib/language.py new file mode 100644 index 00000000..8a873197 --- /dev/null +++ b/plugin.video.dstv.now/resources/lib/language.py @@ -0,0 +1,21 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + DEVICE_ID = 30000 + DEVICE_LINK = 30001 + LIVE_TV = 30002 + CHANNEL = 30003 + SERIES = 30004 + DEVICE_LINK_STEPS = 30005 + REQUEST_ERROR = 30006 + MOVIES = 30007 + SPORT = 30008 + KIDS = 30009 + STREAM_ERROR = 30010 + API_ERROR = 30011 + REFRESH_TOKEN_ERROR = 30012 + CHANNEL_NOT_FOUND = 30013 + + NEXT_PAGE = 30017 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.dstv.now/resources/lib/plugin.py b/plugin.video.dstv.now/resources/lib/plugin.py new file mode 100644 index 00000000..fb40c17c --- /dev/null +++ b/plugin.video.dstv.now/resources/lib/plugin.py @@ -0,0 +1,489 @@ +import codecs +import threading +from xml.sax.saxutils import escape + +import arrow +from kodi_six import xbmcplugin, xbmc +from six.moves import queue + +from slyguy import plugin, gui, userdata, inputstream, signals, settings +from slyguy.session import Session +from slyguy.log import log +from slyguy.util import gzip_extract + +from .api import API +from .language import _ +from .constants import DEFAULT_COUNTRY, EPG_URLS + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + if not plugin.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE_TV, _bold=True), path=plugin.url_for(live_tv)) + folder.add_item(label=_(_.SERIES, _bold=True), path=plugin.url_for(content, title=_.SERIES, tags='TV Shows')) + folder.add_item(label=_(_.MOVIES, _bold=True), path=plugin.url_for(content, title=_.MOVIES, tags='Movies')) + folder.add_item(label=_(_.SPORT, _bold=True), path=plugin.url_for(content, title=_.SPORT, tags='Sport')) + folder.add_item(label=_(_.KIDS, _bold=True), path=plugin.url_for(content, title=_.KIDS, tags='Kids')) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SELECT_PROFILE, path=plugin.url_for(select_profile), art={'thumb': userdata.get('avatar')}, info={'plot': userdata.get('profile_name')}, _kiosk=False, bookmark=False) + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def live_tv(**kwargs): + folder = plugin.Folder(_.LIVE_TV) + + show_events = 2 + + now = arrow.utcnow() + channels = api.channels(events=show_events) + for channel in channels: + plot = u'' + count = 0 + + for event in channel.get('events', []): + start = arrow.get(event['startDateTime']) + end = arrow.get(event['endDateTime']) + if (now > start and now < end) or start > now: + plot += u'[{}] {}\n'.format(start.to('local').format('h:mma'), event['title']) + count += 1 + + if count == show_events+1: + break + + plot = plot.strip(u'\n') + + folder.add_item( + label = _(_.CHANNEL, channel_number=channel['number'], channel_name=channel['name']), + info = {'plot': plot or channel['description']}, + art = {'thumb': channel['channelLogoPaths'].get('XLARGE')}, + path = plugin.url_for(play_channel, id=channel['id'], _is_live=True), + playable = True + ) + + return folder + +@plugin.route() +def content(title, tags, sort='az', category=None, page=0, **kwargs): + folder = plugin.Folder(title) + + page = int(page) + data = api.content(tags, sort, category=category, page=page, pagesize=24) + + if page > 0: + folder.title += ' ({})'.format(page+1) + + if category is None: + category = '' + for section in data['subSections']: + if section['name'].lower() != 'filter': + continue + + for row in section['items']: + split = row['endpoint'].split('filter') + if len(split) == 1: + category = '' + else: + category = split[1].split(';')[0].lstrip('=') + + folder.add_item( + label = row['name'], + path = plugin.url_for(content, title=title, tags=tags, sort=sort, category=category, page=page), + ) + + if not folder.items: + items = _process_rows(data['items']) + folder.add_items(items) + + if data['total'] > ((data['pageSize'] * data['page']) + data['count']): + folder.add_item( + label = _(_.NEXT_PAGE, page=page+2, _bold=True), + path = plugin.url_for(content, title=title, tags=tags, sort=sort, category=category, page=page+1), + ) + + return folder + +def _process_rows(rows): + items = [] + + for row in rows: + if 'program' in row: + item = _process_program(row['program']) + elif 'video' in row: + item = _process_video(row['video']) + else: + continue + + items.append(item) + + return items + +def _get_image(images, type='thumb', size='SMALL'): + if type == 'thumb': + keys = ['poster', 'play-image'] + elif type == 'fanart': + keys = ['hero', ] + + for key in keys: + if key in images and images[key]: + image = images[key] + return image.get(size) or image[list(image)[-1]] + + return None + +def _process_program(program): + return plugin.Item( + label = program['title'], + art = {'thumb': _get_image(program['images']), 'fanart': _get_image(program['images'], 'fanart')}, + info = { + 'plot': program['synopsis'], + 'genre': program['genres'], + #'mediatype': 'tvshow', + }, + path = plugin.url_for(list_seasons, id=program['id']), + ) + +def _process_video(video): + return plugin.Item( + label = video['title'], + info = { + 'plot': video['synopsis'], + 'year': video['yearOfRelease'], + 'duration': video['durationInSeconds'], + 'season': video['seasonNumber'], + 'episode': video['seasonEpisode'], + 'genre': video['genres'], + 'dateadded': video['airDate'], + 'tvshowtitle': video['displayTitle'], + 'mediatype': 'episode' if video['seasonEpisode'] else 'video', #movie + }, + art = {'thumb': _get_image(video['images']), 'fanart': _get_image(video['images'], 'fanart')}, + path = plugin.url_for(play_asset, stream_url=video['videoAssets'][0]['url'], content_id=video['videoAssets'][0]['manItemId']), + playable = True, + ) + +@plugin.route() +def list_seasons(id, **kwargs): + series = api.series(id) + folder = plugin.Folder(series['title']) + + for row in series['seasons']: + folder.add_item( + label = 'Season {}'.format(row['seasonNumber']), + info = {'plot': row.get('synopsis', '')}, + art = {'thumb': _get_image(series['images']), 'fanart': _get_image(series['images'], 'fanart')}, + path = plugin.url_for(episodes, series=id, season=row['seasonNumber']), + ) + + return folder + +@plugin.route() +def episodes(series, season, **kwargs): + series = api.series(series) + folder = plugin.Folder(series['title'], fanart= _get_image(series['images'], 'fanart'), sort_methods=[xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_UNSORTED, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED]) + + for row in series['seasons']: + if int(row['seasonNumber']) != int(season): + continue + + for video in row['videos']: + if not video['seasonEpisode']: + log.debug('Skipping info video item: {}'.format(video['title'])) + continue + + item = _process_video(video) + folder.add_items(item) + + break + + return folder + +@plugin.route() +def search(**kwargs): + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + + for row in api.search(query): + item = plugin.Item( + label = row['title'], + art = {'thumb': row['image'].get('LARGE')}, + info = {}, + ) + + if row['editorialItemType'] == 'Program': + item.path = plugin.url_for(list_seasons, id=row['id']) + elif row['editorialItemType'] == 'Video': + item.path = plugin.url_for(play_video, id=row['id']) + item.playable = True + else: + continue + + folder.add_items(item) + + return folder + +@plugin.route() +def login(**kwargs): + if not _device_link(): + return + + _select_profile() + gui.refresh() + +def _device_link(): + monitor = xbmc.Monitor() + # device_id, code = api.device_code() + timeout = 600 + + with api.device_login() as login_progress: + with gui.progress(_(_.DEVICE_LINK_STEPS, code=login_progress.code), heading=_.DEVICE_LINK) as progress: + for i in range(timeout): + if progress.iscanceled() or not login_progress.is_alive() or monitor.waitForAbort(1): + break + + progress.update(int((i / float(timeout)) * 100)) + + login_progress.stop() + return login_progress.result + +@plugin.route() +@plugin.login_required() +def select_profile(**kwargs): + _select_profile() + gui.refresh() + +def _select_profile(): + options = [] + values = [] + can_delete = [] + default = -1 + + for index, profile in enumerate(api.profiles()): + values.append(profile) + options.append(plugin.Item(label=profile['alias'], art={'thumb': profile['avatar']['uri']})) + + if profile['id'] == userdata.get('profile'): + default = index + userdata.set('avatar', profile['avatar']['uri']) + userdata.set('profile', profile['alias']) + + elif profile['id'] and profile['canDelete']: + can_delete.append(profile) + + index = gui.select(_.SELECT_PROFILE, options=options, preselect=default, useDetails=True) + if index < 0: + return + + selected = values[index] + + if selected == '_delete': + pass + # _delete_profile(can_delete) + elif selected == '_add': + pass + # _add_profile(taken_names=[x['profileName'] for x in profiles], taken_avatars=[avatars[x] for x in avatars]) + else: + _set_profile(selected) + +def _set_profile(profile): + userdata.set('profile', profile['id']) + userdata.set('profile_name', profile['alias']) + userdata.set('avatar', profile['avatar']['uri']) + if profile['id']: + gui.notification(_.PROFILE_ACTIVATED, heading=profile['alias'], icon=profile['avatar']['uri']) + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + userdata.delete('avatar') + userdata.delete('profile') + userdata.delete('profile_name') + gui.refresh() + +@plugin.route() +@plugin.login_required() +def play_asset(stream_url, content_id, **kwargs): + url, license_url, headers = api.play_asset(stream_url, content_id) + + return plugin.Item( + inputstream = inputstream.Widevine(license_url), + headers = headers, + path = url, + ) + +@plugin.route() +@plugin.login_required() +def play_video(id, **kwargs): + url, license_url, headers = api.play_video(id) + + return plugin.Item( + inputstream = inputstream.Widevine(license_url), + headers = headers, + path = url, + ) + +@plugin.route() +@plugin.login_required() +def play_channel(id, **kwargs): + url, license_url, headers = api.play_channel(id) + + return plugin.Item( + inputstream = inputstream.Widevine(license_url, properties={'manifest_update_parameter': 'full'}), + headers = headers, + path = url, + ) + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + data = api.channels() + + with codecs.open(output, 'w', encoding='utf-8') as f: + f.write(u'#EXTM3U\n') + + for row in data: + genres = row.get('genres', []) + genres = ';'.join(genres) if genres else '' + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{channel}" tvg-name="{name}" group-title="{group}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=row['id'], channel=row['number'], name=row['name'], logo=row['channelLogoPaths'].get('XLARGE', ''), + group=genres, path=plugin.url_for(play_channel, id=row['id'], _is_live=True))) + +@plugin.route() +@plugin.merge() +def epg(output, **kwargs): + country = userdata.get('country', DEFAULT_COUNTRY) + epg_url = EPG_URLS.get(country) + + if epg_url: + try: + Session().chunked_dl(epg_url, output) + if epg_url.endswith('.gz'): + gzip_extract(output) + return True + except Exception as e: + log.exception(e) + log.debug('Failed to get remote epg: {}. Fall back to scraping'.format(epg_url)) + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'') + + def process_data(id, data): + program_count = 0 + for event in data: + channel = event['channelTag'] + start = arrow.get(event['startDateTime']).to('utc') + stop = arrow.get(event['endDateTime']).to('utc') + title = event.get('title') + subtitle = event.get('episodeTitle') + series = event.get('seasonNumber') + episode = event.get('episodeNumber') + desc = event.get('longSynopsis') + icon = event.get('thumbnailImagePaths', {}).get('THUMB') + + icon = u''.format(icon) if icon else '' + episode = u'S{}E{}'.format(series, episode) if series and episode else '' + subtitle = u'{}'.format(escape(subtitle)) if subtitle else '' + + f.write(u'{title}{subtitle}{icon}{episode}{desc}'.format( + id=channel, start=start.format('YYYYMMDDHHmmss Z'), stop=stop.format('YYYYMMDDHHmmss Z'), title=escape(title), subtitle=subtitle, episode=episode, icon=icon, desc=escape(desc))) + + ids = [] + no_events = [] + for row in api.channels(): + f.write(u''.format(id=row['id'])) + ids.append(row['id']) + + if not row.get('events'): + no_events.append(row['id']) + + log.debug('{} Channels'.format(len(ids))) + log.debug('No Events: {}'.format(no_events)) + + start = arrow.now('Africa/Johannesburg') + EPG_DAYS = settings.getInt('epg_days', 3) + WORKERS = 3 + + queue_data = queue.Queue() + queue_failed = queue.Queue() + queue_tasks = queue.Queue() + queue_errors = queue.Queue() + + for id in ids: + queue_tasks.put(id) + + def xml_worker(): + while True: + id, data = queue_data.get() + try: + process_data(id, data) + except Exception as e: + queue_errors.put(e) + finally: + queue_data.task_done() + + def worker(): + while True: + id = queue_tasks.get() + try: + data = api.epg(id, start.shift(days=-1), start.shift(days=EPG_DAYS+1), attempts=1) + if not data: + raise Exception() + + queue_data.put([id, data]) + except Exception as e: + queue_failed.put(id) + finally: + queue_tasks.task_done() + + for i in range(WORKERS): + thread = threading.Thread(target=worker) + thread.daemon = True + thread.start() + + thread = threading.Thread(target=xml_worker) + thread.daemon = True + thread.start() + + queue_tasks.join() + queue_data.join() + + if not queue_errors.empty(): + raise Exception('Error processing data') + + while not queue_failed.empty(): + id = queue_failed.get_nowait() + data = api.epg(id, start.shift(days=-1), start.shift(days=EPG_DAYS+1), attempts=1 if id in no_events else 10) + if data: + process_data(id, data) + elif id in no_events: + log.debug('Skipped {}: Expected 0 events'.format(id)) + else: + raise Exception('Failed {}'.format(id)) + + f.write(u'') \ No newline at end of file diff --git a/plugin.video.dstv.now/resources/settings.xml b/plugin.video.dstv.now/resources/settings.xml new file mode 100644 index 00000000..98d8a206 --- /dev/null +++ b/plugin.video.dstv.now/resources/settings.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.foxtel.go/.iptv_merge b/plugin.video.foxtel.go/.iptv_merge new file mode 100644 index 00000000..3b6d2674 --- /dev/null +++ b/plugin.video.foxtel.go/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/Foxtel/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.foxtel.go/__init__.py b/plugin.video.foxtel.go/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.foxtel.go/addon.xml b/plugin.video.foxtel.go/addon.xml new file mode 100644 index 00000000..a3c59c08 --- /dev/null +++ b/plugin.video.foxtel.go/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Foxtel GO anywhere, anytime, any place. Stream Live and On Demand shows from your pack on your favourite devices. + +Subscription required. + true + + + + Add Bookmarks! + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.foxtel.go/default.py b/plugin.video.foxtel.go/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.foxtel.go/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.foxtel.go/fanart.jpg b/plugin.video.foxtel.go/fanart.jpg new file mode 100644 index 00000000..f700efea Binary files /dev/null and b/plugin.video.foxtel.go/fanart.jpg differ diff --git a/plugin.video.foxtel.go/icon.png b/plugin.video.foxtel.go/icon.png new file mode 100644 index 00000000..eb53de93 Binary files /dev/null and b/plugin.video.foxtel.go/icon.png differ diff --git a/plugin.video.foxtel.go/resources/__init__.py b/plugin.video.foxtel.go/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.foxtel.go/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.foxtel.go/resources/language/resource.language.en_gb/strings.po b/plugin.video.foxtel.go/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..c0d02813 --- /dev/null +++ b/plugin.video.foxtel.go/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,147 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Failed to login.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30004" +msgid "You need to de-register a device to add this new device" +msgstr "" + +msgctxt "#30005" +msgid "Live TV" +msgstr "" + +msgctxt "#30006" +msgid "Failed to get video asset.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30007" +msgid "Unable to find a stream for this asset." +msgstr "" + +msgctxt "#30008" +msgid "Save password" +msgstr "" + +msgctxt "#30009" +msgid "Widevine Security Level" +msgstr "" + +msgctxt "#30010" +msgid "TV Shows" +msgstr "" + +msgctxt "#30011" +msgid "Error refresing token.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30012" +msgid "Sports" +msgstr "" + +msgctxt "#30013" +msgid "Movies" +msgstr "" + +msgctxt "#30014" +msgid "Season {season_number}" +msgstr "" + +msgctxt "#30015" +msgid "{title} (S{season} EP{episode})" +msgstr "" + +msgctxt "#30016" +msgid "Go to [B]{title}[/B]" +msgstr "" + +msgctxt "#30017" +msgid "Kids" +msgstr "" + +msgctxt "#30018" +msgid "Hide Locked Content" +msgstr "" + +msgctxt "#30019" +msgid "{channel} - {title}" +msgstr "" + +msgctxt "#30020" +msgid "{label} [COLOR red](LOCKED)[/COLOR]" +msgstr "" + +msgctxt "#30021" +msgid "Device Nickname" +msgstr "" + +msgctxt "#30022" +msgid "Device ID" +msgstr "" + +msgctxt "#30023" +msgid "Recommended" +msgstr "" + +msgctxt "#30024" +msgid "{title} ({subtitle})" +msgstr "" + +msgctxt "#30025" +msgid "Search" +msgstr "" + +msgctxt "#30026" +msgid "Search: {query}" +msgstr "" + +msgctxt "#30027" +msgid "Continue Watching" +msgstr "" + +msgctxt "#30028" +msgid "Watchlist" +msgstr "" + +msgctxt "#30029" +msgid "Legacy Mode (enable if no playback)" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" diff --git a/plugin.video.foxtel.go/resources/lib/__init__.py b/plugin.video.foxtel.go/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.foxtel.go/resources/lib/api.py b/plugin.video.foxtel.go/resources/lib/api.py new file mode 100644 index 00000000..c2456cc7 --- /dev/null +++ b/plugin.video.foxtel.go/resources/lib/api.py @@ -0,0 +1,388 @@ +import hashlib +import uuid + +import arrow +import pyaes + +from slyguy import userdata, gui, settings +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.log import log +from slyguy.util import get_system_arch + +from .constants import HEADERS, AES_IV, API_URL, TYPE_LIVE, TYPE_VOD, STREAM_PRIORITY, LIVE_SITEID, VOD_SITEID, BUNDLE_URL, APP_ID, PLT_DEVICE, SEARCH_URL +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self._session = Session(HEADERS, base_url=API_URL) + self.logged_in = userdata.get('token') != None + + def login(self, username, password, kickdevice=None): + self.logout() + + raw_id = self._format_id(settings.get('device_id')).lower() + device_id = hashlib.sha1(raw_id.encode('utf8')).hexdigest() + + log.debug('Raw device id: {}'.format(raw_id)) + log.debug('Hashed device id: {}'.format(device_id)) + + hex_password = self._hex_password(password, device_id) + + params = { + 'appID' : APP_ID, + 'format': 'json', + } + + payload = { + 'username': username, + 'password': hex_password, + 'deviceId': device_id, + 'accountType': 'foxtel', + } + + if kickdevice: + payload['deviceToKick'] = kickdevice + log.debug('Kicking device: {}'.format(kickdevice)) + + data = self._session.post('/auth.class.api.php/logon/{site_id}'.format(site_id=VOD_SITEID), data=payload, params=params).json() + + response = data['LogonResponse'] + devices = response.get('CurrentDevices', []) + error = response.get('Error') + success = response.get('Success') + + if error: + if not devices or kickdevice: + raise APIError(_(_.LOGIN_ERROR, msg=error.get('Message'))) + + options = [d['Nickname'] for d in devices] + index = gui.select(_.DEREGISTER_CHOOSE, options) + if index < 0: + raise APIError(_(_.LOGIN_ERROR, msg=error.get('Message'))) + + kickdevice = devices[index]['DeviceID'] + + return self.login(username, password, kickdevice=kickdevice) + + userdata.set('token', success['LoginToken']) + userdata.set('deviceid', success['DeviceId']) + userdata.set('entitlements', success.get('Entitlements', '')) + + if settings.getBool('save_password', False): + userdata.set('pswd', password) + log.debug('Password Saved') + + self.logged_in = True + + def _format_id(self, string): + try: + mac_address = uuid.getnode() + if mac_address != uuid.getnode(): + mac_address = '' + except: + mac_address = '' + + system, arch = get_system_arch() + + return string.format(username=userdata.get('username'), mac_address=mac_address, system=system).strip() + + def _hex_password(self, password, device_id): + nickname = self._format_id(settings.get('device_name')) + log.debug('Device nickname: {}'.format(nickname)) + + params = { + 'deviceId': device_id, + 'nickName': nickname, + 'format' : 'json', + } + + secret = self._session.get('/auth.class.api.php/prelogin/{site_id}'.format(site_id=VOD_SITEID), params=params).json()['secret'] + log.debug('Pass Secret: {}{}'.format(secret[:5], 'x'*len(secret[5:]))) + + try: + #python3 + iv = bytes.fromhex(AES_IV) + except AttributeError: + #python2 + iv = str(bytearray.fromhex(AES_IV)) + + encrypter = pyaes.Encrypter(pyaes.AESModeOfOperationCBC(secret.encode('utf8'), iv)) + + ciphertext = encrypter.feed(password) + ciphertext += encrypter.feed() + + try: + #python3 + hex_password = ciphertext.hex() + except AttributeError: + #python2 + hex_password = ciphertext.encode('hex') + + log.debug('Hex password: {}{}'.format(hex_password[:5], 'x'*len(hex_password[5:]))) + + return hex_password + + def assets(self, asset_type, _filter=None, showall=False): + params = { + 'showall': showall, + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'entitlementToken': self._entitlement_token(), + 'sort' : 'latest', + 'format': 'json', + } + + if _filter: + params['filters'] = _filter + + return self._session.get('/categoryTree.class.api.php/GOgetAssets/{site_id}/{asset_type}'.format(site_id=VOD_SITEID, asset_type=asset_type), params=params, timeout=20).json() + + def live_channels(self, _filter=None): + params = { + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'entitlementToken': self._entitlement_token(), + 'format': 'json', + } + + if _filter: + params['filter'] = _filter + + return self._session.get('/categoryTree.class.api.php/GOgetLiveChannels/{site_id}'.format(site_id=LIVE_SITEID), params=params).json() + + def show(self, show_id): + params = { + 'showId': show_id, + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'format': 'json', + } + + return self._session.get('/asset.class.api.php/GOgetAssetData/{site_id}/0'.format(site_id=VOD_SITEID), params=params).json() + + def asset(self, media_type, id): + params = { + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'format': 'json', + } + + if media_type == TYPE_VOD: + site_id = VOD_SITEID + else: + site_id = LIVE_SITEID + + return self._session.get('/asset.class.api.php/GOgetAssetData/{site_id}/{id}'.format(site_id=site_id, id=id), params=params).json() + + def bundle(self, mode=''): + params = { + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'entitlementToken': self._entitlement_token(), + 'apiVersion': 2, + 'filter': '', + 'mode': mode, + 'format': 'json', + } + + return self._session.get(BUNDLE_URL, params=params).json() + + def _sync_token(self, site_id, catalog_name): + self._refresh_token() + + params = { + 'appID': APP_ID, + 'format': 'json', + } + + payload = { + 'loginToken': userdata.get('token'), + 'deviceId': userdata.get('deviceid'), + } + + vod_token = None + live_token = None + + data = self._session.post('/userCatalog.class.api.php/getSyncTokens/{site_id}'.format(site_id=VOD_SITEID), params=params, data=payload).json() + + for token in data.get('tokens', []): + if token['siteId'] == site_id and token['catalogName'] == catalog_name: + return token['token'] + + return None + + def user_catalog(self, catalog_name, site_id=VOD_SITEID): + token = self._sync_token(site_id, catalog_name) + if not token: + return + + params = { + 'syncToken': token, + 'platform': PLT_DEVICE, + 'appID': APP_ID, + 'limit': 100, + 'format': 'json', + } + + return self._session.get('/userCatalog.class.api.php/getCarousel/{site_id}/{catalog_name}'.format(site_id=site_id, catalog_name=catalog_name), params=params).json() + + def epg(self, channel_codes, starttime=None, endtime=None): + now = arrow.utcnow() + + starttime = starttime or now.shift(hours=-2) + endtime = endtime or starttime.shift(days=1) + + params = { + 'filter_starttime': starttime.timestamp, + 'filter_endtime': endtime.timestamp, + 'filter_channels': ','.join(channel_codes), + 'filter_fields': 'EventTitle,ShortSynopsis,StartTimeUTC,EndTimeUTC,RawStartTimeUTC,RawEndTimeUTC,ProgramTitle,EpisodeTitle,Genre,HighDefinition,ClosedCaption,EpisodeNumber,SeriesNumber,ParentalRating,MergedShortSynopsis', + 'format': 'json', + } + + return self._session.get('/epg.class.api.php/getChannelListings/{site_id}'.format(site_id=LIVE_SITEID), params=params).json() + + def search(self, query, type='VOD'): + params = { + 'prod': 'FOXTELNOW' if APP_ID == 'PLAY2' else 'FOXTELGO', + 'idm': '04' if APP_ID == 'PLAY2' else '02', + 'BLOCKED': 'YES', + 'fx': '"{}"'.format(query), + 'sfx': 'type:{}'.format(type), #VOD OR LINEAR + 'limit': 100, + 'offset': 0, + 'dpg': 'R18+', + 'ao': 'N', + 'dopt': '[F0:11]', + 'hwid': '_', + 'REGION': '_', + 'utcOffset': '+1200', + 'swver': '3.3.7', + 'aid': '_', + 'fxid': '_', + 'rid': 'SEARCH5', + } + + return self._session.get(SEARCH_URL, params=params).json() + + def play(self, media_type, id): + self._refresh_token() + + payload = { + 'deviceId': userdata.get('deviceid'), + 'loginToken': userdata.get('token'), + } + + if media_type == TYPE_VOD: + endpoint = 'GOgetVODConfig' + site_id = VOD_SITEID + else: + endpoint = 'GOgetLiveConfig' + site_id = LIVE_SITEID + + params = { + 'rate': 'WIREDHIGH', + 'plt': PLT_DEVICE, + 'appID': 'PLAY2', + 'deviceCaps': hashlib.md5('TR3V0RwAZH3r3L00kingA7SumStuFF{}'.format('L1').encode('utf8')).hexdigest().lower(), + 'format': 'json', + } + + if settings.getBool('legacy_mode', False): + url = 'https://foxtel-go-sw.foxtelplayer.foxtel.com.au/now-mobile-140/api/playback.class.api.php/{endpoint}/{site_id}/1/{id}' + else: + params['plt'] = 'ipstb' + url = 'https://foxtel-go-sw.foxtelplayer.foxtel.com.au/now-box-140/api/playback.class.api.php/{endpoint}/{site_id}/1/{id}' + + data = self._session.post(url.format(endpoint=endpoint, site_id=site_id, id=id), params=params, data=payload).json() + + error = data.get('errorMessage') + + if error: + raise APIError(_(_.PLAYBACK_ERROR, msg=error)) + + streams = sorted(data['media'].get('streams', []), key=lambda s: STREAM_PRIORITY.get(s['profile'].upper(), STREAM_PRIORITY['DEFAULT']), reverse=True) + if not streams: + raise APIError(_.NO_STREAM_ERROR) + + playback_url = streams[0]['url'].replace('cm=yes&','') #replace cm=yes to fix playback + license_url = data['fullLicenceUrl'] + + return playback_url, license_url + + def asset_for_program(self, show_id, program_id): + show = self.show(show_id) + + if show.get('programId') == program_id: + return show + + if 'childAssets' not in show: + return None + + for child in show['childAssets']['items']: + if child.get('programId') == program_id: + return child + + if 'childAssets' not in child: + return None + + for subchild in child['childAssets']['items']: + if subchild.get('programId') == program_id: + return subchild + + return None + + def _refresh_token(self): + params = { + 'appID' : APP_ID, + 'format': 'json', + } + + payload = { + 'username': userdata.get('username'), + 'loginToken': userdata.get('token'), + 'deviceId': userdata.get('deviceid'), + 'accountType': 'foxtel', + } + + password = userdata.get('pswd') + if password: + log.debug('Using Password Login') + payload['password'] = self._hex_password(password, userdata.get('deviceid')) + del payload['loginToken'] + else: + log.debug('Using Token Login') + + data = self._session.post('/auth.class.api.php/logon/{site_id}'.format(site_id=VOD_SITEID), data=payload, params=params).json() + + response = data['LogonResponse'] + error = response.get('Error') + success = response.get('Success') + + if error: + self.logout() + raise APIError(_(_.TOKEN_ERROR, msg=error.get('Message'))) + + userdata.set('token', success['LoginToken']) + userdata.set('deviceid', success['DeviceId']) + userdata.set('entitlements', success.get('Entitlements', '')) + + self.logged_in = True + + def _entitlement_token(self): + entitlements = userdata.get('entitlements') + if not entitlements: + return None + + return hashlib.md5(entitlements.encode('utf8')).hexdigest() + + def logout(self): + userdata.delete('token') + userdata.delete('deviceid') + userdata.delete('pswd') + userdata.delete('entitlements') + self.new_session() \ No newline at end of file diff --git a/plugin.video.foxtel.go/resources/lib/constants.py b/plugin.video.foxtel.go/resources/lib/constants.py new file mode 100644 index 00000000..d10e128f --- /dev/null +++ b/plugin.video.foxtel.go/resources/lib/constants.py @@ -0,0 +1,44 @@ +from slyguy.constants import ADDON_ID + +if ADDON_ID == 'plugin.video.foxtel.now': + APP_ID = 'PLAY2' + URL = 'now-mobile-140' +else: + APP_ID = 'GO2' + URL = 'go-mobile-440' + +HEADERS = { + 'User-Agent': 'okhttp/2.7.5', +} + +BASE_URL = 'https://foxtel-go-sw.foxtelplayer.foxtel.com.au/{}'.format(URL) +API_URL = BASE_URL + '/api{}' +BUNDLE_URL = BASE_URL + '/bundleAPI/getHomeBundle.php' +IMG_URL = BASE_URL + '/imageHelper.php?id={id}:png&w={width}{fragment}' +SEARCH_URL = 'https://foxtel-prod-elb.digitalsmiths.net/sd/foxtel/taps/assets/search/prefix' + +AES_IV = 'b2d40461b54d81c8c6df546051370328' +PLT_DEVICE = 'andr_phone' +EPG_EVENTS_COUNT = 6 + +TYPE_LIVE = '1' +TYPE_VOD = '2' + +LIVE_SITEID = '206' +VOD_SITEID = '296' + +ASSET_MOVIE = '1' +ASSET_TVSHOW = '4' +ASSET_BOTH = '' + +STREAM_PRIORITY = { + 'WIREDHD' : 16, + 'WIREDHIGH': 15, + 'WIFIHD' : 14, + 'WIFIHIGH' : 13, + 'FULL' : 12, + 'WIFILOW' : 11, + '3GHIGH' : 10, + '3GLOW' : 9, + 'DEFAULT' : 0, +} \ No newline at end of file diff --git a/plugin.video.foxtel.go/resources/lib/language.py b/plugin.video.foxtel.go/resources/lib/language.py new file mode 100644 index 00000000..d5d2e8fd --- /dev/null +++ b/plugin.video.foxtel.go/resources/lib/language.py @@ -0,0 +1,34 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LOGIN_ERROR = 30003 + DEREGISTER_CHOOSE = 30004 + LIVE_TV = 30005 + PLAYBACK_ERROR = 30006 + NO_STREAM_ERROR = 30007 + SAVE_PASSWORD = 30008 + WIDEVINE_LEVEL = 30009 + TV_SHOWS = 30010 + TOKEN_ERROR = 30011 + SPORTS = 30012 + MOVIES = 30013 + SEASON = 30014 + EPISODE_MENU_TITLE = 30015 + GO_TO_SHOW_CONTEXT = 30016 + KIDS = 30017 + HIDE_LOCKED = 30018 + CHANNEL = 30019 + LOCKED = 30020 + DEVICE_NAME = 30021 + DEVICE_ID = 30022 + RECOMMENDED = 30023 + EPISODE_SUBTITLE = 30024 + SEARCH = 30025 + SEARCH_FOR = 30026 + CONTINUE_WATCHING = 30027 + WATCHLIST = 30028 + LEGACY_MODE = 30029 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.foxtel.go/resources/lib/plugin.py b/plugin.video.foxtel.go/resources/lib/plugin.py new file mode 100644 index 00000000..3a3a00c4 --- /dev/null +++ b/plugin.video.foxtel.go/resources/lib/plugin.py @@ -0,0 +1,481 @@ +import codecs + +import arrow +from kodi_six import xbmcplugin + +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.log import log +from slyguy.exceptions import PluginError + +from .api import API +from .language import _ +from .constants import IMG_URL, TYPE_LIVE, TYPE_VOD, LIVE_SITEID, VOD_SITEID, ASSET_TVSHOW, ASSET_MOVIE, ASSET_BOTH, HEADERS, EPG_EVENTS_COUNT + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def index(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE_TV, _bold=True), path=plugin.url_for(live_tv)) + folder.add_item(label=_(_.TV_SHOWS, _bold=True), path=plugin.url_for(assets, title=_.TV_SHOWS, asset_type=ASSET_TVSHOW)) + folder.add_item(label=_(_.MOVIES, _bold=True), path=plugin.url_for(assets, title=_.MOVIES, asset_type=ASSET_MOVIE)) + folder.add_item(label=_(_.SPORTS, _bold=True), path=plugin.url_for(assets, title=_.SPORTS, _filter=5)) + folder.add_item(label=_(_.KIDS, _bold=True), path=plugin.url_for(kids)) + folder.add_item(label=_(_.RECOMMENDED, _bold=True), path=plugin.url_for(recommended)) + folder.add_item(label=_(_.CONTINUE_WATCHING, _bold=True), path=plugin.url_for(user_catalog, catalog_name='continue-watching')) + folder.add_item(label=_(_.WATCHLIST, _bold=True), path=plugin.url_for(user_catalog, catalog_name='watchlist')) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def recommended(**kwargs): + folder = plugin.Folder(_.RECOMMENDED) + _bundle(folder) + return folder + +@plugin.route() +def user_catalog(catalog_name, **kwargs): + data = api.user_catalog(catalog_name) + if not data: + return plugin.Folder() + + folder = plugin.Folder(data['name']) + + items = _parse_elements(data['assets'], from_menu=True) + folder.add_items(items) + + return folder + +@plugin.route() +def search(**kwargs): + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + data = api.search(query) + + #total_results = data['groupCount'] #pagination + entitlements = _get_entitlements() + + for group in sorted(data['groups'], key=lambda d: d['score'], reverse=True): + hitcount = int(group['hitCount']) + + for hit in group['hits']: + meta = hit['metadata'] + + try: + channelTag = hit['relevantSchedules'][0]['feature']['channelTag'] + except: + try: + channelTag = hit['relevantSchedules'][0]['channelTag'] + except: + channelTag = None + + meta['locked'] = entitlements and channelTag and channelTag not in entitlements + + if meta['locked'] and settings.getBool('hide_locked'): + continue + + season = int(meta.get('seasonNumber', 0)) + episode = int(meta.get('episodeNumber', 0)) + + if meta['contentType'].upper() == 'MOVIE': + folder.add_item( + label = _(_.LOCKED, label=meta['title']) if meta['locked'] else meta['title'], + info = { + 'mediatype': 'movie', + 'plot': meta.get('shortSynopsis'), + # 'duration': int(elem.get('duration') or 0), + 'year': int(meta.get('yearOfRelease') or 0), + }, + art = {'thumb': 'https://images1.resources.foxtel.com.au/{}?w=400'.format(hit['images']['title']['portrait'][0]['URI']), 'fanart':'https://images1.resources.foxtel.com.au/{}?w=800'.format(hit['images']['title']['landscape'][0]['URI'])}, + playable = True, + path = plugin.url_for(play_program, show_id=meta['titleId'], program_id=hit['id']), + ) + elif hitcount <= 1 and season > 0 and episode > 0: + label = _(_.EPISODE_MENU_TITLE, title=meta['title'], season=season, episode=episode) + go_to_show = plugin.url_for(show, show_id=meta['titleId']) + + folder.add_item( + label = _(_.LOCKED, label=label) if meta['locked'] else label, + info = { + 'plot': 'S{} EP{} - {}\n\n{}'.format(season, episode, meta.get('episodeTitle', meta['title']), meta.get('shortSynopsis')), + 'episode': episode, + 'season': season, + 'tvshowtitle': meta['title'], + # 'duration': int(elem.get('duration') or 0), + 'year': int(meta.get('yearOfRelease') or 0), + 'mediatype': 'episode', + }, + art = {'thumb': 'https://images1.resources.foxtel.com.au/{}?w=400'.format(hit['images']['episode']['landscape'][0]['URI']), 'fanart': 'https://images1.resources.foxtel.com.au/{}?w=800'.format(hit['images']['episode']['landscape'][0]['URI'])}, + playable = True, + path = plugin.url_for(play_program, show_id=meta['titleId'], program_id=hit['id']), + context = [(_(_.GO_TO_SHOW_CONTEXT, title=meta['title']), "Container.Update({})".format(go_to_show))], + ) + else: + folder.add_item( + label = _(_.LOCKED, label=meta['title']) if meta['locked'] else meta['title'], + info = { + 'tvshowtitle': meta['title'], + 'year': int(meta.get('yearOfRelease') or 0), + }, + art = {'thumb': 'https://images1.resources.foxtel.com.au/{}?w=400'.format(hit['images']['default']['landscape'][0]['URI']), 'fanart': 'https://images1.resources.foxtel.com.au/{}?w=800'.format(hit['images']['default']['landscape'][0]['URI'])}, + path = plugin.url_for(show, show_id=meta['titleId']), + ) + + + return folder + +@plugin.route() +def kids(**kwargs): + folder = plugin.Folder(_.KIDS) + _bundle(folder, mode='kids') + return folder + +def _bundle(folder, mode=''): + data = api.bundle(mode=mode) + + for block in data['blocks']: + if 'data' not in block: + continue + + folder.add_item( + label = block['name'], + path = plugin.url_for(assets, title=block['name'], _filter=block['data'], menu=0), + ) + +@plugin.route() +def assets(title, asset_type=ASSET_BOTH, _filter=None, menu=1, **kwargs): + menu = int(menu) + folder = plugin.Folder(title) + + if menu: + data = api.assets(asset_type, _filter, showall=False) + + def _add_menu(menuitem): + item = plugin.Item( + label = menuitem['text'], + path = plugin.url_for(assets, title=title, asset_type=asset_type, _filter=menuitem['data'], menu=int(len(menuitem.get('menuItem', [])) > 0)), + ) + + folder.add_items([item]) + + if not _filter: + for menuitem in data['menu']['menuItem']: + _add_menu(menuitem) + else: + for row in data['content'].get('contentGroup', []): + item = plugin.Item( + label = row['name'], + path = plugin.url_for(assets, title=title, asset_type=asset_type, _filter=row['data'], menu=0), + ) + + folder.add_items([item]) + else: + data = api.assets(asset_type, _filter, showall=True) + + elements = [] + for e in data['content'].get('contentGroup', []): + elements.extend(e.get('items', [])) + + items = _parse_elements(elements, from_menu=True) + folder.add_items(items) + + return folder + +def _parse_elements(elements, from_menu=False): + entitlements = _get_entitlements() + + items = [] + for elem in elements: + elem['locked'] = entitlements and elem['channelCode'] not in entitlements + + if elem['locked'] and settings.getBool('hide_locked'): + continue + + if elem['type'] == 'movie': + item = _parse_movie(elem) + + elif elem['type'] == 'episode': + item = _parse_episode(elem, from_menu=from_menu) + + elif elem['type'] == 'show': + item = _parse_show(elem) + + elif elem['type'] == 'series': + log.debug('Series! You should no longer see this. Let me know if you do...') + continue + + else: + continue + + items.append(item) + + return items + +def _parse_movie(elem): + return plugin.Item( + label = _(_.LOCKED, label=elem['title']) if elem['locked'] else elem['title'], + art = {'thumb': _image(elem['image']), 'fanart': _image(elem.get('widescreenImage', elem['image']), 600)}, + info = { + 'mediatype': 'movie', + 'plot': elem.get('synopsis'), + 'duration': int(elem.get('duration') or 0), + 'year': int(elem.get('year') or 0), + }, + path = plugin.url_for(play, media_type=TYPE_VOD, id=elem.get('mediaId', elem['id'])), + playable = True, + ) + +def _parse_show(elem): + return plugin.Item( + label = _(_.LOCKED, label=elem['title']) if elem['locked'] else elem['title'], + art = {'thumb': _image(elem['image']), 'fanart': _image(elem.get('widescreenImage', elem['image']), 600)}, + info = { + 'plot': elem.get('synopsis'), + 'tvshowtitle': elem['title'], + 'year': int(elem.get('year') or 0), + }, + path = plugin.url_for(show, show_id=elem['showId']), + ) + +def _parse_episode(elem, from_menu=False): + context = [] + + art = {'thumb': _image(elem['image'])} + + if from_menu: + if 'subtitle'in elem: + label = _(_.EPISODE_SUBTITLE, title=elem['title'], subtitle=elem['subtitle'].rsplit('-')[0].strip()) + elif 'season' in elem and 'episodeNumber' in elem: + label = _(_.EPISODE_MENU_TITLE, title=elem['title'], season=elem['season'], episode=elem['episodeNumber']) + else: + label = elem['title'] or elem['episodeTitle'] + + go_to_show = plugin.url_for(show, show_id=elem['showId']) + context.append((_(_.GO_TO_SHOW_CONTEXT, title=elem['title']), "Container.Update({})".format(go_to_show))) + + art['fanart'] = _image(elem.get('widescreenImage', elem['image']), 600) + else: + label = elem['episodeTitle'] or elem['title'] + + if elem['locked']: + label = _(_.LOCKED, label=label) + + return plugin.Item( + label = label, + art = art, + info = { + 'plot': elem.get('synopsis'), + 'episode': int(elem.get('episodeNumber') or 0), + 'season': int(elem.get('season') or 0), + 'tvshowtitle': elem['title'], + 'duration': int(elem.get('duration') or 0), + 'year': int(elem.get('year') or 0), + 'mediatype': 'episode', + }, + path = plugin.url_for(play, media_type=TYPE_VOD, id=elem.get('mediaId', elem['id'])), + context = context, + playable = True, + ) + +@plugin.route() +def show(show_id, season=None, **kwargs): + season = season + data = api.show(show_id) + folder = plugin.Folder(data['title'], fanart=_image(data.get('widescreenImage', data['image']), 600), sort_methods=[xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_UNSORTED, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED]) + + flatten = False + seasons = data['childAssets']['items'] + if len(seasons) == 1 and len(seasons[0]['childAssets']['items']) == 1: + flatten = True + + if season == None and not flatten: + for item in seasons: + folder.add_item( + label = _(_.SEASON, season_number=item['season']), + path = plugin.url_for(show, show_id=show_id, season=item['season']), + art = {'thumb': _image(data['image'])}, + ) + else: + for item in seasons: + if season and int(item['season']) != int(season): + continue + + items = _parse_elements(item['childAssets']['items']) + folder.add_items(items) + + return folder + +def _get_entitlements(): + entitlements = userdata.get('entitlements') + if not entitlements: + return [] + + return entitlements.split(',') + +def _image(id, width=400, fragment=''): + if fragment: + fragment = '#{}'.format(fragment) + return IMG_URL.format(id=id, width=width, fragment=fragment) + +@plugin.route() +def live_tv(_filter=None, **kwargs): + folder = plugin.Folder(_.LIVE_TV) + + data = api.live_channels(_filter) + + if not _filter: + for genre in data['genres']['items']: + folder.add_item( + label = genre['title'], + path = plugin.url_for(live_tv, _filter=genre['data']), + ) + else: + entitlements = _get_entitlements() + + channels = [] + codes = [] + for elem in sorted(data['liveChannel'], key=lambda e: e['order']): + elem['locked'] = entitlements and elem['channelCode'] not in entitlements + + if elem['locked'] and settings.getBool('hide_locked'): + continue + else: + channels.append(elem) + codes.append(elem['channelCode']) + + epg = {} + for row in api.epg(codes): + epg[row['SourceChannel']['ChannelTag']] = row['ChannelSchedule']['EventList'] + + for elem in channels: + plot = '' + now = arrow.utcnow() + count = 0 + + for event in epg.get(elem['channelCode'], []): + start = arrow.get(int(event['StartTimeUTC'])) + end = arrow.get(int(event['EndTimeUTC'])) + if (now > start and now < end) or start > now: + plot += '[{}] {}\n'.format(start.to('local').format('h:mma'), event['EventTitle']) + count += 1 + + if count == EPG_EVENTS_COUNT: + plot = plot.strip('\n') + break + + label = _(_.CHANNEL, channel=elem['channelId'], title=elem['title']) + if elem['locked']: + label = _(_.LOCKED, label=label) + + folder.add_item( + label = label, + art = {'thumb': _image('{id}:{site_id}:CHANNEL:IMAGE'.format(id=elem['id'], site_id=LIVE_SITEID, name=elem['title']), fragment=elem['title'])}, + info = { + 'plot': plot, + }, + path = plugin.url_for(play, media_type=TYPE_LIVE, id=elem['id'], _is_live=True), + playable = True, + ) + + return folder + +@plugin.route() +@plugin.login_required() +def play_program(show_id, program_id, **kwargs): + elem = api.asset_for_program(show_id, program_id) + return _play(TYPE_VOD, elem['id']) + +@plugin.route() +@plugin.login_required() +def play(media_type, id, **kwargs): + return _play(media_type, id) + +def _play(media_type, id): + url, license_url = api.play(media_type, id) + + item = plugin.Item( + inputstream = inputstream.Widevine(license_key=license_url), + path = url, + headers = HEADERS, + ) + + if media_type == TYPE_LIVE: + item.inputstream.properties['manifest_update_parameter'] = 'full' + + return item + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +@plugin.login_required() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + data = api.live_channels() + + genres = {} + for genre in data['genres']['items'][1:]: #skip first "All channels" genre + channels = api.live_channels(_filter=genre['data'])['liveChannel'] + for channel in channels: + genres[channel['channelCode']] = genre['title'] + + entitlements = _get_entitlements() + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for elem in sorted(data['liveChannel'], key=lambda e: e['order']): + elem['locked'] = entitlements and elem['channelCode'] not in entitlements + + if elem['locked'] and settings.getBool('hide_locked'): + continue + + label = elem['title'] + if elem['locked']: + label = _(_.LOCKED, label=label) + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{channel}" channel-id="{channel}" group-title="{group}" tvg-name="{name}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=elem['channelCode'], channel=elem['channelId'], logo=_image('{id}:{site_id}:CHANNEL:IMAGE'.format(id=elem['id'], site_id=LIVE_SITEID), fragment=elem['title']), + name=label, group=genres.get(elem['channelCode'], ''), path=plugin.url_for(play, media_type=TYPE_LIVE, id=elem['id'], _is_live=True))) \ No newline at end of file diff --git a/plugin.video.foxtel.go/resources/settings.xml b/plugin.video.foxtel.go/resources/settings.xml new file mode 100644 index 00000000..d40ff39f --- /dev/null +++ b/plugin.video.foxtel.go/resources/settings.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.foxtel.now/.iptv_merge b/plugin.video.foxtel.now/.iptv_merge new file mode 100644 index 00000000..3b6d2674 --- /dev/null +++ b/plugin.video.foxtel.now/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/Foxtel/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.foxtel.now/__init__.py b/plugin.video.foxtel.now/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.foxtel.now/addon.xml b/plugin.video.foxtel.now/addon.xml new file mode 100644 index 00000000..9e1301bf --- /dev/null +++ b/plugin.video.foxtel.now/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Stream Foxtel Now on demand and live TV, sport and news from around the world. + +Subscription required. + true + + + + Add Bookmarks! + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.foxtel.now/default.py b/plugin.video.foxtel.now/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.foxtel.now/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.foxtel.now/fanart.jpg b/plugin.video.foxtel.now/fanart.jpg new file mode 100644 index 00000000..f32bd84a Binary files /dev/null and b/plugin.video.foxtel.now/fanart.jpg differ diff --git a/plugin.video.foxtel.now/icon.png b/plugin.video.foxtel.now/icon.png new file mode 100644 index 00000000..ad6bcb9d Binary files /dev/null and b/plugin.video.foxtel.now/icon.png differ diff --git a/plugin.video.foxtel.now/resources/__init__.py b/plugin.video.foxtel.now/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.foxtel.now/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.foxtel.now/resources/language/resource.language.en_gb/strings.po b/plugin.video.foxtel.now/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..c0d02813 --- /dev/null +++ b/plugin.video.foxtel.now/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,147 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Failed to login.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30004" +msgid "You need to de-register a device to add this new device" +msgstr "" + +msgctxt "#30005" +msgid "Live TV" +msgstr "" + +msgctxt "#30006" +msgid "Failed to get video asset.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30007" +msgid "Unable to find a stream for this asset." +msgstr "" + +msgctxt "#30008" +msgid "Save password" +msgstr "" + +msgctxt "#30009" +msgid "Widevine Security Level" +msgstr "" + +msgctxt "#30010" +msgid "TV Shows" +msgstr "" + +msgctxt "#30011" +msgid "Error refresing token.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30012" +msgid "Sports" +msgstr "" + +msgctxt "#30013" +msgid "Movies" +msgstr "" + +msgctxt "#30014" +msgid "Season {season_number}" +msgstr "" + +msgctxt "#30015" +msgid "{title} (S{season} EP{episode})" +msgstr "" + +msgctxt "#30016" +msgid "Go to [B]{title}[/B]" +msgstr "" + +msgctxt "#30017" +msgid "Kids" +msgstr "" + +msgctxt "#30018" +msgid "Hide Locked Content" +msgstr "" + +msgctxt "#30019" +msgid "{channel} - {title}" +msgstr "" + +msgctxt "#30020" +msgid "{label} [COLOR red](LOCKED)[/COLOR]" +msgstr "" + +msgctxt "#30021" +msgid "Device Nickname" +msgstr "" + +msgctxt "#30022" +msgid "Device ID" +msgstr "" + +msgctxt "#30023" +msgid "Recommended" +msgstr "" + +msgctxt "#30024" +msgid "{title} ({subtitle})" +msgstr "" + +msgctxt "#30025" +msgid "Search" +msgstr "" + +msgctxt "#30026" +msgid "Search: {query}" +msgstr "" + +msgctxt "#30027" +msgid "Continue Watching" +msgstr "" + +msgctxt "#30028" +msgid "Watchlist" +msgstr "" + +msgctxt "#30029" +msgid "Legacy Mode (enable if no playback)" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" diff --git a/plugin.video.foxtel.now/resources/lib/__init__.py b/plugin.video.foxtel.now/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.foxtel.now/resources/lib/api.py b/plugin.video.foxtel.now/resources/lib/api.py new file mode 100644 index 00000000..c2456cc7 --- /dev/null +++ b/plugin.video.foxtel.now/resources/lib/api.py @@ -0,0 +1,388 @@ +import hashlib +import uuid + +import arrow +import pyaes + +from slyguy import userdata, gui, settings +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.log import log +from slyguy.util import get_system_arch + +from .constants import HEADERS, AES_IV, API_URL, TYPE_LIVE, TYPE_VOD, STREAM_PRIORITY, LIVE_SITEID, VOD_SITEID, BUNDLE_URL, APP_ID, PLT_DEVICE, SEARCH_URL +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self._session = Session(HEADERS, base_url=API_URL) + self.logged_in = userdata.get('token') != None + + def login(self, username, password, kickdevice=None): + self.logout() + + raw_id = self._format_id(settings.get('device_id')).lower() + device_id = hashlib.sha1(raw_id.encode('utf8')).hexdigest() + + log.debug('Raw device id: {}'.format(raw_id)) + log.debug('Hashed device id: {}'.format(device_id)) + + hex_password = self._hex_password(password, device_id) + + params = { + 'appID' : APP_ID, + 'format': 'json', + } + + payload = { + 'username': username, + 'password': hex_password, + 'deviceId': device_id, + 'accountType': 'foxtel', + } + + if kickdevice: + payload['deviceToKick'] = kickdevice + log.debug('Kicking device: {}'.format(kickdevice)) + + data = self._session.post('/auth.class.api.php/logon/{site_id}'.format(site_id=VOD_SITEID), data=payload, params=params).json() + + response = data['LogonResponse'] + devices = response.get('CurrentDevices', []) + error = response.get('Error') + success = response.get('Success') + + if error: + if not devices or kickdevice: + raise APIError(_(_.LOGIN_ERROR, msg=error.get('Message'))) + + options = [d['Nickname'] for d in devices] + index = gui.select(_.DEREGISTER_CHOOSE, options) + if index < 0: + raise APIError(_(_.LOGIN_ERROR, msg=error.get('Message'))) + + kickdevice = devices[index]['DeviceID'] + + return self.login(username, password, kickdevice=kickdevice) + + userdata.set('token', success['LoginToken']) + userdata.set('deviceid', success['DeviceId']) + userdata.set('entitlements', success.get('Entitlements', '')) + + if settings.getBool('save_password', False): + userdata.set('pswd', password) + log.debug('Password Saved') + + self.logged_in = True + + def _format_id(self, string): + try: + mac_address = uuid.getnode() + if mac_address != uuid.getnode(): + mac_address = '' + except: + mac_address = '' + + system, arch = get_system_arch() + + return string.format(username=userdata.get('username'), mac_address=mac_address, system=system).strip() + + def _hex_password(self, password, device_id): + nickname = self._format_id(settings.get('device_name')) + log.debug('Device nickname: {}'.format(nickname)) + + params = { + 'deviceId': device_id, + 'nickName': nickname, + 'format' : 'json', + } + + secret = self._session.get('/auth.class.api.php/prelogin/{site_id}'.format(site_id=VOD_SITEID), params=params).json()['secret'] + log.debug('Pass Secret: {}{}'.format(secret[:5], 'x'*len(secret[5:]))) + + try: + #python3 + iv = bytes.fromhex(AES_IV) + except AttributeError: + #python2 + iv = str(bytearray.fromhex(AES_IV)) + + encrypter = pyaes.Encrypter(pyaes.AESModeOfOperationCBC(secret.encode('utf8'), iv)) + + ciphertext = encrypter.feed(password) + ciphertext += encrypter.feed() + + try: + #python3 + hex_password = ciphertext.hex() + except AttributeError: + #python2 + hex_password = ciphertext.encode('hex') + + log.debug('Hex password: {}{}'.format(hex_password[:5], 'x'*len(hex_password[5:]))) + + return hex_password + + def assets(self, asset_type, _filter=None, showall=False): + params = { + 'showall': showall, + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'entitlementToken': self._entitlement_token(), + 'sort' : 'latest', + 'format': 'json', + } + + if _filter: + params['filters'] = _filter + + return self._session.get('/categoryTree.class.api.php/GOgetAssets/{site_id}/{asset_type}'.format(site_id=VOD_SITEID, asset_type=asset_type), params=params, timeout=20).json() + + def live_channels(self, _filter=None): + params = { + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'entitlementToken': self._entitlement_token(), + 'format': 'json', + } + + if _filter: + params['filter'] = _filter + + return self._session.get('/categoryTree.class.api.php/GOgetLiveChannels/{site_id}'.format(site_id=LIVE_SITEID), params=params).json() + + def show(self, show_id): + params = { + 'showId': show_id, + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'format': 'json', + } + + return self._session.get('/asset.class.api.php/GOgetAssetData/{site_id}/0'.format(site_id=VOD_SITEID), params=params).json() + + def asset(self, media_type, id): + params = { + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'format': 'json', + } + + if media_type == TYPE_VOD: + site_id = VOD_SITEID + else: + site_id = LIVE_SITEID + + return self._session.get('/asset.class.api.php/GOgetAssetData/{site_id}/{id}'.format(site_id=site_id, id=id), params=params).json() + + def bundle(self, mode=''): + params = { + 'plt': PLT_DEVICE, + 'appID': APP_ID, + 'entitlementToken': self._entitlement_token(), + 'apiVersion': 2, + 'filter': '', + 'mode': mode, + 'format': 'json', + } + + return self._session.get(BUNDLE_URL, params=params).json() + + def _sync_token(self, site_id, catalog_name): + self._refresh_token() + + params = { + 'appID': APP_ID, + 'format': 'json', + } + + payload = { + 'loginToken': userdata.get('token'), + 'deviceId': userdata.get('deviceid'), + } + + vod_token = None + live_token = None + + data = self._session.post('/userCatalog.class.api.php/getSyncTokens/{site_id}'.format(site_id=VOD_SITEID), params=params, data=payload).json() + + for token in data.get('tokens', []): + if token['siteId'] == site_id and token['catalogName'] == catalog_name: + return token['token'] + + return None + + def user_catalog(self, catalog_name, site_id=VOD_SITEID): + token = self._sync_token(site_id, catalog_name) + if not token: + return + + params = { + 'syncToken': token, + 'platform': PLT_DEVICE, + 'appID': APP_ID, + 'limit': 100, + 'format': 'json', + } + + return self._session.get('/userCatalog.class.api.php/getCarousel/{site_id}/{catalog_name}'.format(site_id=site_id, catalog_name=catalog_name), params=params).json() + + def epg(self, channel_codes, starttime=None, endtime=None): + now = arrow.utcnow() + + starttime = starttime or now.shift(hours=-2) + endtime = endtime or starttime.shift(days=1) + + params = { + 'filter_starttime': starttime.timestamp, + 'filter_endtime': endtime.timestamp, + 'filter_channels': ','.join(channel_codes), + 'filter_fields': 'EventTitle,ShortSynopsis,StartTimeUTC,EndTimeUTC,RawStartTimeUTC,RawEndTimeUTC,ProgramTitle,EpisodeTitle,Genre,HighDefinition,ClosedCaption,EpisodeNumber,SeriesNumber,ParentalRating,MergedShortSynopsis', + 'format': 'json', + } + + return self._session.get('/epg.class.api.php/getChannelListings/{site_id}'.format(site_id=LIVE_SITEID), params=params).json() + + def search(self, query, type='VOD'): + params = { + 'prod': 'FOXTELNOW' if APP_ID == 'PLAY2' else 'FOXTELGO', + 'idm': '04' if APP_ID == 'PLAY2' else '02', + 'BLOCKED': 'YES', + 'fx': '"{}"'.format(query), + 'sfx': 'type:{}'.format(type), #VOD OR LINEAR + 'limit': 100, + 'offset': 0, + 'dpg': 'R18+', + 'ao': 'N', + 'dopt': '[F0:11]', + 'hwid': '_', + 'REGION': '_', + 'utcOffset': '+1200', + 'swver': '3.3.7', + 'aid': '_', + 'fxid': '_', + 'rid': 'SEARCH5', + } + + return self._session.get(SEARCH_URL, params=params).json() + + def play(self, media_type, id): + self._refresh_token() + + payload = { + 'deviceId': userdata.get('deviceid'), + 'loginToken': userdata.get('token'), + } + + if media_type == TYPE_VOD: + endpoint = 'GOgetVODConfig' + site_id = VOD_SITEID + else: + endpoint = 'GOgetLiveConfig' + site_id = LIVE_SITEID + + params = { + 'rate': 'WIREDHIGH', + 'plt': PLT_DEVICE, + 'appID': 'PLAY2', + 'deviceCaps': hashlib.md5('TR3V0RwAZH3r3L00kingA7SumStuFF{}'.format('L1').encode('utf8')).hexdigest().lower(), + 'format': 'json', + } + + if settings.getBool('legacy_mode', False): + url = 'https://foxtel-go-sw.foxtelplayer.foxtel.com.au/now-mobile-140/api/playback.class.api.php/{endpoint}/{site_id}/1/{id}' + else: + params['plt'] = 'ipstb' + url = 'https://foxtel-go-sw.foxtelplayer.foxtel.com.au/now-box-140/api/playback.class.api.php/{endpoint}/{site_id}/1/{id}' + + data = self._session.post(url.format(endpoint=endpoint, site_id=site_id, id=id), params=params, data=payload).json() + + error = data.get('errorMessage') + + if error: + raise APIError(_(_.PLAYBACK_ERROR, msg=error)) + + streams = sorted(data['media'].get('streams', []), key=lambda s: STREAM_PRIORITY.get(s['profile'].upper(), STREAM_PRIORITY['DEFAULT']), reverse=True) + if not streams: + raise APIError(_.NO_STREAM_ERROR) + + playback_url = streams[0]['url'].replace('cm=yes&','') #replace cm=yes to fix playback + license_url = data['fullLicenceUrl'] + + return playback_url, license_url + + def asset_for_program(self, show_id, program_id): + show = self.show(show_id) + + if show.get('programId') == program_id: + return show + + if 'childAssets' not in show: + return None + + for child in show['childAssets']['items']: + if child.get('programId') == program_id: + return child + + if 'childAssets' not in child: + return None + + for subchild in child['childAssets']['items']: + if subchild.get('programId') == program_id: + return subchild + + return None + + def _refresh_token(self): + params = { + 'appID' : APP_ID, + 'format': 'json', + } + + payload = { + 'username': userdata.get('username'), + 'loginToken': userdata.get('token'), + 'deviceId': userdata.get('deviceid'), + 'accountType': 'foxtel', + } + + password = userdata.get('pswd') + if password: + log.debug('Using Password Login') + payload['password'] = self._hex_password(password, userdata.get('deviceid')) + del payload['loginToken'] + else: + log.debug('Using Token Login') + + data = self._session.post('/auth.class.api.php/logon/{site_id}'.format(site_id=VOD_SITEID), data=payload, params=params).json() + + response = data['LogonResponse'] + error = response.get('Error') + success = response.get('Success') + + if error: + self.logout() + raise APIError(_(_.TOKEN_ERROR, msg=error.get('Message'))) + + userdata.set('token', success['LoginToken']) + userdata.set('deviceid', success['DeviceId']) + userdata.set('entitlements', success.get('Entitlements', '')) + + self.logged_in = True + + def _entitlement_token(self): + entitlements = userdata.get('entitlements') + if not entitlements: + return None + + return hashlib.md5(entitlements.encode('utf8')).hexdigest() + + def logout(self): + userdata.delete('token') + userdata.delete('deviceid') + userdata.delete('pswd') + userdata.delete('entitlements') + self.new_session() \ No newline at end of file diff --git a/plugin.video.foxtel.now/resources/lib/constants.py b/plugin.video.foxtel.now/resources/lib/constants.py new file mode 100644 index 00000000..d10e128f --- /dev/null +++ b/plugin.video.foxtel.now/resources/lib/constants.py @@ -0,0 +1,44 @@ +from slyguy.constants import ADDON_ID + +if ADDON_ID == 'plugin.video.foxtel.now': + APP_ID = 'PLAY2' + URL = 'now-mobile-140' +else: + APP_ID = 'GO2' + URL = 'go-mobile-440' + +HEADERS = { + 'User-Agent': 'okhttp/2.7.5', +} + +BASE_URL = 'https://foxtel-go-sw.foxtelplayer.foxtel.com.au/{}'.format(URL) +API_URL = BASE_URL + '/api{}' +BUNDLE_URL = BASE_URL + '/bundleAPI/getHomeBundle.php' +IMG_URL = BASE_URL + '/imageHelper.php?id={id}:png&w={width}{fragment}' +SEARCH_URL = 'https://foxtel-prod-elb.digitalsmiths.net/sd/foxtel/taps/assets/search/prefix' + +AES_IV = 'b2d40461b54d81c8c6df546051370328' +PLT_DEVICE = 'andr_phone' +EPG_EVENTS_COUNT = 6 + +TYPE_LIVE = '1' +TYPE_VOD = '2' + +LIVE_SITEID = '206' +VOD_SITEID = '296' + +ASSET_MOVIE = '1' +ASSET_TVSHOW = '4' +ASSET_BOTH = '' + +STREAM_PRIORITY = { + 'WIREDHD' : 16, + 'WIREDHIGH': 15, + 'WIFIHD' : 14, + 'WIFIHIGH' : 13, + 'FULL' : 12, + 'WIFILOW' : 11, + '3GHIGH' : 10, + '3GLOW' : 9, + 'DEFAULT' : 0, +} \ No newline at end of file diff --git a/plugin.video.foxtel.now/resources/lib/language.py b/plugin.video.foxtel.now/resources/lib/language.py new file mode 100644 index 00000000..d5d2e8fd --- /dev/null +++ b/plugin.video.foxtel.now/resources/lib/language.py @@ -0,0 +1,34 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LOGIN_ERROR = 30003 + DEREGISTER_CHOOSE = 30004 + LIVE_TV = 30005 + PLAYBACK_ERROR = 30006 + NO_STREAM_ERROR = 30007 + SAVE_PASSWORD = 30008 + WIDEVINE_LEVEL = 30009 + TV_SHOWS = 30010 + TOKEN_ERROR = 30011 + SPORTS = 30012 + MOVIES = 30013 + SEASON = 30014 + EPISODE_MENU_TITLE = 30015 + GO_TO_SHOW_CONTEXT = 30016 + KIDS = 30017 + HIDE_LOCKED = 30018 + CHANNEL = 30019 + LOCKED = 30020 + DEVICE_NAME = 30021 + DEVICE_ID = 30022 + RECOMMENDED = 30023 + EPISODE_SUBTITLE = 30024 + SEARCH = 30025 + SEARCH_FOR = 30026 + CONTINUE_WATCHING = 30027 + WATCHLIST = 30028 + LEGACY_MODE = 30029 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.foxtel.now/resources/lib/plugin.py b/plugin.video.foxtel.now/resources/lib/plugin.py new file mode 100644 index 00000000..3a3a00c4 --- /dev/null +++ b/plugin.video.foxtel.now/resources/lib/plugin.py @@ -0,0 +1,481 @@ +import codecs + +import arrow +from kodi_six import xbmcplugin + +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.log import log +from slyguy.exceptions import PluginError + +from .api import API +from .language import _ +from .constants import IMG_URL, TYPE_LIVE, TYPE_VOD, LIVE_SITEID, VOD_SITEID, ASSET_TVSHOW, ASSET_MOVIE, ASSET_BOTH, HEADERS, EPG_EVENTS_COUNT + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def index(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE_TV, _bold=True), path=plugin.url_for(live_tv)) + folder.add_item(label=_(_.TV_SHOWS, _bold=True), path=plugin.url_for(assets, title=_.TV_SHOWS, asset_type=ASSET_TVSHOW)) + folder.add_item(label=_(_.MOVIES, _bold=True), path=plugin.url_for(assets, title=_.MOVIES, asset_type=ASSET_MOVIE)) + folder.add_item(label=_(_.SPORTS, _bold=True), path=plugin.url_for(assets, title=_.SPORTS, _filter=5)) + folder.add_item(label=_(_.KIDS, _bold=True), path=plugin.url_for(kids)) + folder.add_item(label=_(_.RECOMMENDED, _bold=True), path=plugin.url_for(recommended)) + folder.add_item(label=_(_.CONTINUE_WATCHING, _bold=True), path=plugin.url_for(user_catalog, catalog_name='continue-watching')) + folder.add_item(label=_(_.WATCHLIST, _bold=True), path=plugin.url_for(user_catalog, catalog_name='watchlist')) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def recommended(**kwargs): + folder = plugin.Folder(_.RECOMMENDED) + _bundle(folder) + return folder + +@plugin.route() +def user_catalog(catalog_name, **kwargs): + data = api.user_catalog(catalog_name) + if not data: + return plugin.Folder() + + folder = plugin.Folder(data['name']) + + items = _parse_elements(data['assets'], from_menu=True) + folder.add_items(items) + + return folder + +@plugin.route() +def search(**kwargs): + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + data = api.search(query) + + #total_results = data['groupCount'] #pagination + entitlements = _get_entitlements() + + for group in sorted(data['groups'], key=lambda d: d['score'], reverse=True): + hitcount = int(group['hitCount']) + + for hit in group['hits']: + meta = hit['metadata'] + + try: + channelTag = hit['relevantSchedules'][0]['feature']['channelTag'] + except: + try: + channelTag = hit['relevantSchedules'][0]['channelTag'] + except: + channelTag = None + + meta['locked'] = entitlements and channelTag and channelTag not in entitlements + + if meta['locked'] and settings.getBool('hide_locked'): + continue + + season = int(meta.get('seasonNumber', 0)) + episode = int(meta.get('episodeNumber', 0)) + + if meta['contentType'].upper() == 'MOVIE': + folder.add_item( + label = _(_.LOCKED, label=meta['title']) if meta['locked'] else meta['title'], + info = { + 'mediatype': 'movie', + 'plot': meta.get('shortSynopsis'), + # 'duration': int(elem.get('duration') or 0), + 'year': int(meta.get('yearOfRelease') or 0), + }, + art = {'thumb': 'https://images1.resources.foxtel.com.au/{}?w=400'.format(hit['images']['title']['portrait'][0]['URI']), 'fanart':'https://images1.resources.foxtel.com.au/{}?w=800'.format(hit['images']['title']['landscape'][0]['URI'])}, + playable = True, + path = plugin.url_for(play_program, show_id=meta['titleId'], program_id=hit['id']), + ) + elif hitcount <= 1 and season > 0 and episode > 0: + label = _(_.EPISODE_MENU_TITLE, title=meta['title'], season=season, episode=episode) + go_to_show = plugin.url_for(show, show_id=meta['titleId']) + + folder.add_item( + label = _(_.LOCKED, label=label) if meta['locked'] else label, + info = { + 'plot': 'S{} EP{} - {}\n\n{}'.format(season, episode, meta.get('episodeTitle', meta['title']), meta.get('shortSynopsis')), + 'episode': episode, + 'season': season, + 'tvshowtitle': meta['title'], + # 'duration': int(elem.get('duration') or 0), + 'year': int(meta.get('yearOfRelease') or 0), + 'mediatype': 'episode', + }, + art = {'thumb': 'https://images1.resources.foxtel.com.au/{}?w=400'.format(hit['images']['episode']['landscape'][0]['URI']), 'fanart': 'https://images1.resources.foxtel.com.au/{}?w=800'.format(hit['images']['episode']['landscape'][0]['URI'])}, + playable = True, + path = plugin.url_for(play_program, show_id=meta['titleId'], program_id=hit['id']), + context = [(_(_.GO_TO_SHOW_CONTEXT, title=meta['title']), "Container.Update({})".format(go_to_show))], + ) + else: + folder.add_item( + label = _(_.LOCKED, label=meta['title']) if meta['locked'] else meta['title'], + info = { + 'tvshowtitle': meta['title'], + 'year': int(meta.get('yearOfRelease') or 0), + }, + art = {'thumb': 'https://images1.resources.foxtel.com.au/{}?w=400'.format(hit['images']['default']['landscape'][0]['URI']), 'fanart': 'https://images1.resources.foxtel.com.au/{}?w=800'.format(hit['images']['default']['landscape'][0]['URI'])}, + path = plugin.url_for(show, show_id=meta['titleId']), + ) + + + return folder + +@plugin.route() +def kids(**kwargs): + folder = plugin.Folder(_.KIDS) + _bundle(folder, mode='kids') + return folder + +def _bundle(folder, mode=''): + data = api.bundle(mode=mode) + + for block in data['blocks']: + if 'data' not in block: + continue + + folder.add_item( + label = block['name'], + path = plugin.url_for(assets, title=block['name'], _filter=block['data'], menu=0), + ) + +@plugin.route() +def assets(title, asset_type=ASSET_BOTH, _filter=None, menu=1, **kwargs): + menu = int(menu) + folder = plugin.Folder(title) + + if menu: + data = api.assets(asset_type, _filter, showall=False) + + def _add_menu(menuitem): + item = plugin.Item( + label = menuitem['text'], + path = plugin.url_for(assets, title=title, asset_type=asset_type, _filter=menuitem['data'], menu=int(len(menuitem.get('menuItem', [])) > 0)), + ) + + folder.add_items([item]) + + if not _filter: + for menuitem in data['menu']['menuItem']: + _add_menu(menuitem) + else: + for row in data['content'].get('contentGroup', []): + item = plugin.Item( + label = row['name'], + path = plugin.url_for(assets, title=title, asset_type=asset_type, _filter=row['data'], menu=0), + ) + + folder.add_items([item]) + else: + data = api.assets(asset_type, _filter, showall=True) + + elements = [] + for e in data['content'].get('contentGroup', []): + elements.extend(e.get('items', [])) + + items = _parse_elements(elements, from_menu=True) + folder.add_items(items) + + return folder + +def _parse_elements(elements, from_menu=False): + entitlements = _get_entitlements() + + items = [] + for elem in elements: + elem['locked'] = entitlements and elem['channelCode'] not in entitlements + + if elem['locked'] and settings.getBool('hide_locked'): + continue + + if elem['type'] == 'movie': + item = _parse_movie(elem) + + elif elem['type'] == 'episode': + item = _parse_episode(elem, from_menu=from_menu) + + elif elem['type'] == 'show': + item = _parse_show(elem) + + elif elem['type'] == 'series': + log.debug('Series! You should no longer see this. Let me know if you do...') + continue + + else: + continue + + items.append(item) + + return items + +def _parse_movie(elem): + return plugin.Item( + label = _(_.LOCKED, label=elem['title']) if elem['locked'] else elem['title'], + art = {'thumb': _image(elem['image']), 'fanart': _image(elem.get('widescreenImage', elem['image']), 600)}, + info = { + 'mediatype': 'movie', + 'plot': elem.get('synopsis'), + 'duration': int(elem.get('duration') or 0), + 'year': int(elem.get('year') or 0), + }, + path = plugin.url_for(play, media_type=TYPE_VOD, id=elem.get('mediaId', elem['id'])), + playable = True, + ) + +def _parse_show(elem): + return plugin.Item( + label = _(_.LOCKED, label=elem['title']) if elem['locked'] else elem['title'], + art = {'thumb': _image(elem['image']), 'fanart': _image(elem.get('widescreenImage', elem['image']), 600)}, + info = { + 'plot': elem.get('synopsis'), + 'tvshowtitle': elem['title'], + 'year': int(elem.get('year') or 0), + }, + path = plugin.url_for(show, show_id=elem['showId']), + ) + +def _parse_episode(elem, from_menu=False): + context = [] + + art = {'thumb': _image(elem['image'])} + + if from_menu: + if 'subtitle'in elem: + label = _(_.EPISODE_SUBTITLE, title=elem['title'], subtitle=elem['subtitle'].rsplit('-')[0].strip()) + elif 'season' in elem and 'episodeNumber' in elem: + label = _(_.EPISODE_MENU_TITLE, title=elem['title'], season=elem['season'], episode=elem['episodeNumber']) + else: + label = elem['title'] or elem['episodeTitle'] + + go_to_show = plugin.url_for(show, show_id=elem['showId']) + context.append((_(_.GO_TO_SHOW_CONTEXT, title=elem['title']), "Container.Update({})".format(go_to_show))) + + art['fanart'] = _image(elem.get('widescreenImage', elem['image']), 600) + else: + label = elem['episodeTitle'] or elem['title'] + + if elem['locked']: + label = _(_.LOCKED, label=label) + + return plugin.Item( + label = label, + art = art, + info = { + 'plot': elem.get('synopsis'), + 'episode': int(elem.get('episodeNumber') or 0), + 'season': int(elem.get('season') or 0), + 'tvshowtitle': elem['title'], + 'duration': int(elem.get('duration') or 0), + 'year': int(elem.get('year') or 0), + 'mediatype': 'episode', + }, + path = plugin.url_for(play, media_type=TYPE_VOD, id=elem.get('mediaId', elem['id'])), + context = context, + playable = True, + ) + +@plugin.route() +def show(show_id, season=None, **kwargs): + season = season + data = api.show(show_id) + folder = plugin.Folder(data['title'], fanart=_image(data.get('widescreenImage', data['image']), 600), sort_methods=[xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_UNSORTED, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED]) + + flatten = False + seasons = data['childAssets']['items'] + if len(seasons) == 1 and len(seasons[0]['childAssets']['items']) == 1: + flatten = True + + if season == None and not flatten: + for item in seasons: + folder.add_item( + label = _(_.SEASON, season_number=item['season']), + path = plugin.url_for(show, show_id=show_id, season=item['season']), + art = {'thumb': _image(data['image'])}, + ) + else: + for item in seasons: + if season and int(item['season']) != int(season): + continue + + items = _parse_elements(item['childAssets']['items']) + folder.add_items(items) + + return folder + +def _get_entitlements(): + entitlements = userdata.get('entitlements') + if not entitlements: + return [] + + return entitlements.split(',') + +def _image(id, width=400, fragment=''): + if fragment: + fragment = '#{}'.format(fragment) + return IMG_URL.format(id=id, width=width, fragment=fragment) + +@plugin.route() +def live_tv(_filter=None, **kwargs): + folder = plugin.Folder(_.LIVE_TV) + + data = api.live_channels(_filter) + + if not _filter: + for genre in data['genres']['items']: + folder.add_item( + label = genre['title'], + path = plugin.url_for(live_tv, _filter=genre['data']), + ) + else: + entitlements = _get_entitlements() + + channels = [] + codes = [] + for elem in sorted(data['liveChannel'], key=lambda e: e['order']): + elem['locked'] = entitlements and elem['channelCode'] not in entitlements + + if elem['locked'] and settings.getBool('hide_locked'): + continue + else: + channels.append(elem) + codes.append(elem['channelCode']) + + epg = {} + for row in api.epg(codes): + epg[row['SourceChannel']['ChannelTag']] = row['ChannelSchedule']['EventList'] + + for elem in channels: + plot = '' + now = arrow.utcnow() + count = 0 + + for event in epg.get(elem['channelCode'], []): + start = arrow.get(int(event['StartTimeUTC'])) + end = arrow.get(int(event['EndTimeUTC'])) + if (now > start and now < end) or start > now: + plot += '[{}] {}\n'.format(start.to('local').format('h:mma'), event['EventTitle']) + count += 1 + + if count == EPG_EVENTS_COUNT: + plot = plot.strip('\n') + break + + label = _(_.CHANNEL, channel=elem['channelId'], title=elem['title']) + if elem['locked']: + label = _(_.LOCKED, label=label) + + folder.add_item( + label = label, + art = {'thumb': _image('{id}:{site_id}:CHANNEL:IMAGE'.format(id=elem['id'], site_id=LIVE_SITEID, name=elem['title']), fragment=elem['title'])}, + info = { + 'plot': plot, + }, + path = plugin.url_for(play, media_type=TYPE_LIVE, id=elem['id'], _is_live=True), + playable = True, + ) + + return folder + +@plugin.route() +@plugin.login_required() +def play_program(show_id, program_id, **kwargs): + elem = api.asset_for_program(show_id, program_id) + return _play(TYPE_VOD, elem['id']) + +@plugin.route() +@plugin.login_required() +def play(media_type, id, **kwargs): + return _play(media_type, id) + +def _play(media_type, id): + url, license_url = api.play(media_type, id) + + item = plugin.Item( + inputstream = inputstream.Widevine(license_key=license_url), + path = url, + headers = HEADERS, + ) + + if media_type == TYPE_LIVE: + item.inputstream.properties['manifest_update_parameter'] = 'full' + + return item + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +@plugin.login_required() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + data = api.live_channels() + + genres = {} + for genre in data['genres']['items'][1:]: #skip first "All channels" genre + channels = api.live_channels(_filter=genre['data'])['liveChannel'] + for channel in channels: + genres[channel['channelCode']] = genre['title'] + + entitlements = _get_entitlements() + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for elem in sorted(data['liveChannel'], key=lambda e: e['order']): + elem['locked'] = entitlements and elem['channelCode'] not in entitlements + + if elem['locked'] and settings.getBool('hide_locked'): + continue + + label = elem['title'] + if elem['locked']: + label = _(_.LOCKED, label=label) + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{channel}" channel-id="{channel}" group-title="{group}" tvg-name="{name}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=elem['channelCode'], channel=elem['channelId'], logo=_image('{id}:{site_id}:CHANNEL:IMAGE'.format(id=elem['id'], site_id=LIVE_SITEID), fragment=elem['title']), + name=label, group=genres.get(elem['channelCode'], ''), path=plugin.url_for(play, media_type=TYPE_LIVE, id=elem['id'], _is_live=True))) \ No newline at end of file diff --git a/plugin.video.foxtel.now/resources/settings.xml b/plugin.video.foxtel.now/resources/settings.xml new file mode 100644 index 00000000..d40ff39f --- /dev/null +++ b/plugin.video.foxtel.now/resources/settings.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.jellytelly/__init__.py b/plugin.video.jellytelly/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.jellytelly/addon.xml b/plugin.video.jellytelly/addon.xml new file mode 100644 index 00000000..655d4be1 --- /dev/null +++ b/plugin.video.jellytelly/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Safe Christian shows, movies and devotionals that inspires your kids, makes them laugh, and encourages their own creativity. + +Subscription required. + true + + + + Fix series + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.jellytelly/default.py b/plugin.video.jellytelly/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.jellytelly/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.jellytelly/fanart.jpg b/plugin.video.jellytelly/fanart.jpg new file mode 100644 index 00000000..9a865804 Binary files /dev/null and b/plugin.video.jellytelly/fanart.jpg differ diff --git a/plugin.video.jellytelly/icon.png b/plugin.video.jellytelly/icon.png new file mode 100644 index 00000000..fe13ec5f Binary files /dev/null and b/plugin.video.jellytelly/icon.png differ diff --git a/plugin.video.jellytelly/resources/__init__.py b/plugin.video.jellytelly/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.jellytelly/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.jellytelly/resources/language/resource.language.en_gb/strings.po b/plugin.video.jellytelly/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..e619814d --- /dev/null +++ b/plugin.video.jellytelly/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,170 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Failed to login.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30004" +msgid "Watchlist" +msgstr "" + +msgctxt "#30005" +msgid "Favourites" +msgstr "" + +msgctxt "#30006" +msgid "All Shows" +msgstr "" + +msgctxt "#30007" +msgid "Popular" +msgstr "" + +msgctxt "#30008" +msgid "New Shows" +msgstr "" + +msgctxt "#30009" +msgid "Devotionals" +msgstr "" + +msgctxt "#30010" +msgid "Add to Watchlist" +msgstr "" + +msgctxt "#30011" +msgid "Remove from Watchlist" +msgstr "" + +msgctxt "#30012" +msgid "Add to Minno Favourites" +msgstr "" + +msgctxt "#30013" +msgid "Remove from Minno Favourites" +msgstr "" + +msgctxt "#30014" +msgid "Session expired" +msgstr "" + +msgctxt "#30015" +msgid "Usually caused by another device logging in.\n" +"Tip: Enable Save Password in settings to allow for auto-relogin" +msgstr "" + +msgctxt "#30016" +msgid "Save Password" +msgstr "" + +msgctxt "#30017" +msgid "{title} added to favourites" +msgstr "" + +msgctxt "#30018" +msgid "{title} added to Watchlist" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32034" +msgid "General" +msgstr "" + +msgctxt "#32035" +msgid "Playback" +msgstr "" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "" + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "" + +msgctxt "#32023" +msgid "Use Inputstream HLS" +msgstr "" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "" + +msgctxt "#32036" +msgid "Advanced" +msgstr "" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "" + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" diff --git a/plugin.video.jellytelly/resources/lib/__init__.py b/plugin.video.jellytelly/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.jellytelly/resources/lib/api.py b/plugin.video.jellytelly/resources/lib/api.py new file mode 100644 index 00000000..0425a2ad --- /dev/null +++ b/plugin.video.jellytelly/resources/lib/api.py @@ -0,0 +1,133 @@ +import hashlib + +from slyguy import userdata, settings +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.log import log + +from .constants import HEADERS, API_URL +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + self._session = Session(HEADERS, base_url=API_URL) + self._set_authentication() + + def _set_authentication(self): + access_token = userdata.get('token') + if not access_token: + return + + self._session.headers.update({'Authorization': 'Token token="{}"'.format(access_token)}) + self.logged_in = True + + def _require_auth(self): + r = self._session.post('/watchlist', headers={'Accept': 'version=5'}) + if r.status_code == 200: + return + + log.debug('Session expired') + + pswd = userdata.get('pswd') + if not pswd: + self.logout() + raise APIError(_.SESSION_EXPIRED_DESC, heading=_.SESSION_EXPIRED) + + log.debug('Logging in with saved password') + self.login(userdata.get('username'), pswd) + + def apps(self, key=None): + self._require_auth() + + data = self._session.get('/logged_in_apps', headers={'Accept': 'version=4'}).json() + if not key: + return data + + return data.get(key) + + def add_watchlist(self, series_id): + self._require_auth() + + params = {'series_id': series_id} + r = self._session.post('/watchlist', params=params, headers={'Accept': 'version=5'}) + return r.status_code == 200 + + def del_watchlist(self, series_id): + self._require_auth() + + r = self._session.delete('/watchlist/{}'.format(series_id), headers={'Accept': 'version=5'}) + return r.status_code == 204 + + def search(self, query): + params = {'query': query} + return self._session.post('/search', params=params, headers={'Accept': 'version=6'}).json() + + def series(self, series_id): + return self._session.get('/series/{}'.format(series_id), headers={'Accept': 'version=4'}).json() + + def login(self, username, password): + device_id = hashlib.md5(username.lower().strip().encode('utf8')).hexdigest()[:16] + + payload = { + 'email': username, + 'password': password, + 'device_type': 'android', + 'device_id': device_id, + } + + data = self._session.post('/login', data=payload).json() + if 'errors' in data: + raise APIError(_(_.LOGIN_ERROR, msg=data['errors'])) + + userdata.set('device_id', device_id) + userdata.set('token', data['user_info']['auth_token']) + userdata.set('user_id', data['user_info']['id']) + + if settings.getBool('save_password', False): + userdata.set('pswd', password) + + self._set_authentication() + + def add_favourite(self, video_id): + self._require_auth() + + params = {'video_id': video_id} + r = self._session.post('/favorites', params=params, headers={'Accept': 'version=4'}) + return r.status_code == 200 + + def del_favourite(self, video_id): + self._require_auth() + + r = self._session.delete('/favorites/{}'.format(video_id), headers={'Accept': 'version=4'}) + return r.status_code == 204 + + def streams(self, series_id, video_id): + self._require_auth() + + videos = self.series(series_id)['videos'] + + for video in videos: + if str(video['id']) == str(video_id): + return video['streams'] + + return [] + + def favourites(self): + self._require_auth() + + return self._session.get('/users/{}/favorites'.format(userdata.get('user_id')), headers={'Accept': 'version=3'}).json()['favorite_videos'] + + def user(self): + self._require_auth() + + return self._session.get('/users/{}'.format(userdata.get('user_id'))).json() + + def logout(self): + userdata.delete('token') + userdata.delete('deviceid') + userdata.delete('user_id') + self.new_session() \ No newline at end of file diff --git a/plugin.video.jellytelly/resources/lib/constants.py b/plugin.video.jellytelly/resources/lib/constants.py new file mode 100644 index 00000000..c76c7ec1 --- /dev/null +++ b/plugin.video.jellytelly/resources/lib/constants.py @@ -0,0 +1,3 @@ +HEADERS = {'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 8.1.0; MI 5 Build/OPM7.181205.001)'} + +API_URL = 'https://api.gominno.com{}' \ No newline at end of file diff --git a/plugin.video.jellytelly/resources/lib/language.py b/plugin.video.jellytelly/resources/lib/language.py new file mode 100644 index 00000000..59474119 --- /dev/null +++ b/plugin.video.jellytelly/resources/lib/language.py @@ -0,0 +1,23 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LOGIN_ERROR = 30003 + WATCHLIST = 30004 + FAVOURITES = 30005 + ALL_SHOWS = 30006 + POPULAR = 30007 + NEW_SHOWS = 30008 + DEVOTIONALS = 30009 + ADD_WATCHLIST = 30010 + REMOVE_WATCHLIST = 30011 + ADD_FAVOURITE = 30012 + REMOVE_FAVOURITE = 30013 + SESSION_EXPIRED = 30014 + SESSION_EXPIRED_DESC = 30015 + SAVE_PASSWORD = 30016 + FAVOURITE_ADDED = 30017 + WATCHLIST_ADDED = 30018 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.jellytelly/resources/lib/plugin.py b/plugin.video.jellytelly/resources/lib/plugin.py new file mode 100644 index 00000000..9e5a4a49 --- /dev/null +++ b/plugin.video.jellytelly/resources/lib/plugin.py @@ -0,0 +1,181 @@ +from kodi_six import xbmcplugin + +from slyguy import plugin, gui, userdata, signals, settings +from slyguy.log import log +from slyguy.exceptions import PluginError + +from .api import API +from .language import _ + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def index(**kwargs): + folder = plugin.Folder() + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_.ALL_SHOWS, path=plugin.url_for(apps, key='series_by_label', title=_.ALL_SHOWS)) + folder.add_item(label=_.POPULAR, path=plugin.url_for(apps, key='popular_series', title=_.POPULAR)) + folder.add_item(label=_.NEW_SHOWS, path=plugin.url_for(apps, key='new_episodes', title=_.NEW_SHOWS)) + folder.add_item(label=_.DEVOTIONALS, path=plugin.url_for(apps, key='devotionals', title=_.DEVOTIONALS)) + + folder.add_item(label=_.WATCHLIST, path=plugin.url_for(watchlist)) + folder.add_item(label=_.FAVOURITES, path=plugin.url_for(favourites)) + folder.add_item(label=_.SEARCH, path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_.BOOKMARKS, path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), bookmark=False) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def apps(key, title, **kwargs): + folder = plugin.Folder(title) + + for row in api.apps(key): + item = _parse_series(row) + folder.add_items([item]) + + return folder + +@plugin.route() +def watchlist(**kwargs): + folder = plugin.Folder(_.WATCHLIST) + + for row in api.apps('watchlist'): + item = _parse_series(row) + item.context = [(_.REMOVE_WATCHLIST, "RunPlugin({})".format(plugin.url_for(del_watchlist, series_id=row['id'])))] + folder.add_items([item]) + + return folder + +@plugin.route() +def favourites(**kwargs): + folder = plugin.Folder(_.FAVOURITES) + + for row in api.favourites(): + item = _parse_video(row) + item.context = [(_.REMOVE_FAVOURITE, "RunPlugin({})".format(plugin.url_for(del_favourite, video_id=row['id'])))] + folder.add_items([item]) + + return folder + +def _parse_series(row): + return plugin.Item( + label = row['name'], + info = {'plot': row['description']}, + art = {'thumb': row.get('images', {}).get('medium') or row.get('imageUrl')}, + path = plugin.url_for(series, series_id=row['id']), + context = [(_.ADD_WATCHLIST, "RunPlugin({})".format(plugin.url_for(add_watchlist, series_id=row['id'], title=row['name'])))], + ) + +def _parse_video(row): + return plugin.Item( + label = row['name'], + info = { + 'plot': row['description'], + 'duration': row.get('length'), + 'season': row.get('season'), + 'episode': row.get('episode'), + }, + art = {'thumb': row.get('images', {}).get('medium') or row.get('image_url')}, + path = plugin.url_for(play, series_id=row['series_id'], video_id=row['id']), + context = [(_.ADD_FAVOURITE, "RunPlugin({})".format(plugin.url_for(add_favourite, video_id=row['id'], title=row['name'])))], + playable = True, + ) + +@plugin.route() +def series(series_id, **kwargs): + data = api.series(series_id) + + folder = plugin.Folder(data['name'], fanart=data.get('images', {}).get('large') or data.get('imageUrl'), sort_methods=[xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED, xbmcplugin.SORT_METHOD_UNSORTED]) + + for row in data['videos']: + item = _parse_video(row) + folder.add_items([item]) + + return folder + +@plugin.route() +@plugin.login_required() +def play(series_id, video_id, **kwargs): + streams = api.streams(series_id, video_id) + + streams = sorted(streams, key=lambda x: (x['quality'] == 'hls', x.get('height')), reverse=True) + selected = streams[0] + + return plugin.Item( + path = selected['link'], + ) + +@plugin.route() +def search(**kwargs): + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + + rows = api.search(query) + for row in rows: + item = _parse_series(row) + folder.add_items([item]) + + return folder + +@plugin.route() +@plugin.login_required() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +def add_watchlist(series_id, title, **kwargs): + api.add_watchlist(series_id) + gui.notification(_(_.WATCHLIST_ADDED, title=title)) + +@plugin.route() +def del_watchlist(series_id, **kwargs): + api.del_watchlist(series_id) + gui.refresh() + +@plugin.route() +def add_favourite(video_id, title, **kwargs): + api.add_favourite(video_id) + gui.notification(_(_.FAVOURITE_ADDED, title=title)) + +@plugin.route() +def del_favourite(video_id, **kwargs): + api.del_favourite(video_id) + gui.refresh() \ No newline at end of file diff --git a/plugin.video.jellytelly/resources/settings.xml b/plugin.video.jellytelly/resources/settings.xml new file mode 100644 index 00000000..33dc43cd --- /dev/null +++ b/plugin.video.jellytelly/resources/settings.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.kayo.sports/.iptv_merge b/plugin.video.kayo.sports/.iptv_merge new file mode 100644 index 00000000..09a38ab1 --- /dev/null +++ b/plugin.video.kayo.sports/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/Kayo/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.kayo.sports/__init__.py b/plugin.video.kayo.sports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.kayo.sports/addon.xml b/plugin.video.kayo.sports/addon.xml new file mode 100644 index 00000000..0efcf194 --- /dev/null +++ b/plugin.video.kayo.sports/addon.xml @@ -0,0 +1,24 @@ + + + + + + + video + + + + Watch your favourite sports live & on demand with Kayo Sports. + +Subscription required. + true + + + + Add Auto CDN setting + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.kayo.sports/default.py b/plugin.video.kayo.sports/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.kayo.sports/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.kayo.sports/fanart.jpg b/plugin.video.kayo.sports/fanart.jpg new file mode 100644 index 00000000..c4d889a9 Binary files /dev/null and b/plugin.video.kayo.sports/fanart.jpg differ diff --git a/plugin.video.kayo.sports/icon.png b/plugin.video.kayo.sports/icon.png new file mode 100644 index 00000000..2b474f10 Binary files /dev/null and b/plugin.video.kayo.sports/icon.png differ diff --git a/plugin.video.kayo.sports/resources/__init__.py b/plugin.video.kayo.sports/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.kayo.sports/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.kayo.sports/resources/language/resource.language.en_gb/strings.po b/plugin.video.kayo.sports/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..c912ea6b --- /dev/null +++ b/plugin.video.kayo.sports/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,246 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Failed to login.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30004" +msgid "Failed to get asset.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30005" +msgid "Select Profile" +msgstr "" + +msgctxt "#30006" +msgid "Add Profile" +msgstr "" + +msgctxt "#30007" +msgid "Shows" +msgstr "" + +msgctxt "#30008" +msgid "Sports" +msgstr "" + +msgctxt "#30009" +msgid "Unable to find suitable stream for this asset" +msgstr "" + +msgctxt "#30010" +msgid "{title} [Starts {humanize}]" +msgstr "" + +msgctxt "#30011" +msgid "{title} [LIVE]" +msgstr "" + +msgctxt "#30013" +msgid "Select Profile" +msgstr "" + +msgctxt "#30014" +msgid "Show Hero Contents" +msgstr "" + +msgctxt "#30015" +msgid "Set Reminder" +msgstr "" + +msgctxt "#30016" +msgid "Remove Reminder" +msgstr "" + +msgctxt "#30017" +msgid "Reminder Set" +msgstr "" + +msgctxt "#30018" +msgid "Reminder Removed" +msgstr "" + +msgctxt "#30019" +msgid "This event hasn't started yet.\n" +"It starts {start}.\n" +"Tip: You can set a reminder via the context menu." +msgstr "" + +msgctxt "#30021" +msgid "Watch" +msgstr "" + +msgctxt "#30022" +msgid "Close" +msgstr "" + +msgctxt "#30023" +msgid "{event} has started!" +msgstr "" + +msgctxt "#30025" +msgid "Inputstream HLS is required to watch this content\n" +"Check Inputstream Adaptive is installed and enabled" +msgstr "" + +msgctxt "#30026" +msgid "Search: {query} ({page}/{total_pages})" +msgstr "" + +msgctxt "#30027" +msgid "Featured" +msgstr "" + +msgctxt "#30028" +msgid "Next Page ({next})" +msgstr "" + +msgctxt "#30029" +msgid "Live Channels" +msgstr "" + +msgctxt "#30030" +msgid "Delete Profile" +msgstr "" + +msgctxt "#30031" +msgid "Profile Activated" +msgstr "" + +msgctxt "#30032" +msgid "Random Pick" +msgstr "" + +msgctxt "#30033" +msgid "Select Avatar" +msgstr "" + +msgctxt "#30034" +msgid "Used" +msgstr "" + +msgctxt "#30035" +msgid "Not Used" +msgstr "" + +msgctxt "#30036" +msgid "Profile Name" +msgstr "" + +msgctxt "#30037" +msgid "{name} is already being used" +msgstr "" + +msgctxt "#30038" +msgid "Select profile to delete" +msgstr "" + +msgctxt "#30039" +msgid "Delete {name}'s profile?" +msgstr "" + +msgctxt "#30040" +msgid "Profile history, watchlist, and activity will be deleted. There is no way to undo this." +msgstr "" + +msgctxt "#30041" +msgid "Profile Deleted" +msgstr "" + +msgctxt "#30042" +msgid "Login with" +msgstr "" + +msgctxt "#30043" +msgid "Device Link" +msgstr "" + +msgctxt "#30044" +msgid "Email / Password" +msgstr "" + +msgctxt "#30045" +msgid "1. Go to: [B]{url}[/B]\n" +"2. Enter code: [B]{code}[/B]" +msgstr "" + +msgctxt "#30046" +msgid "Widevine L1 Secure Device" +msgstr "" + +msgctxt "#30047" +msgid "Failed to refresh token.\n" +"[B]Try logout and then login again[/B]\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30048" +msgid "Preferred CDN" +msgstr "" + +msgctxt "#30049" +msgid "Akamai" +msgstr "" + +msgctxt "#30050" +msgid "CloudFront" +msgstr "" + +msgctxt "#30051" +msgid "Failed to fetch auth token\n" +"Make sure your IP address is allowed (not geo-blocked)\n" +"Try accessing the content via other means (offical app / website) to confirm it's a Kodi add-on issue" +msgstr "" + +msgctxt "#30052" +msgid "Auto" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" \ No newline at end of file diff --git a/plugin.video.kayo.sports/resources/lib/__init__.py b/plugin.video.kayo.sports/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.kayo.sports/resources/lib/api.py b/plugin.video.kayo.sports/resources/lib/api.py new file mode 100644 index 00000000..da353653 --- /dev/null +++ b/plugin.video.kayo.sports/resources/lib/api.py @@ -0,0 +1,211 @@ +from time import time + +from slyguy import settings, userdata, mem_cache +from slyguy.log import log +from slyguy.session import Session +from slyguy.exceptions import Error + +from .constants import * +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + self._auth_header = {} + + self._session = Session(HEADERS) + self._set_authentication() + + @mem_cache.cached(60*10) + def _config(self): + return self._session.get(CONFIG_URL).json() + + def _set_authentication(self): + access_token = userdata.get('access_token') + if not access_token: + return + + self._auth_header = {'authorization': 'Bearer {}'.format(access_token)} + self.logged_in = True + + def _oauth_token(self, data, _raise=True): + token_data = self._session.post('https://auth.streamotion.com.au/oauth/token', json=data, headers={'User-Agent': 'okhttp/3.10.0'}, error_msg=_.TOKEN_ERROR).json() + + if 'error' in token_data: + error = _.REFRESH_TOKEN_ERROR if data.get('grant_type') == 'refresh_token' else _.LOGIN_ERROR + if _raise: + raise APIError(_(error, msg=token_data.get('error_description'))) + else: + return False, token_data + + userdata.set('access_token', token_data['access_token']) + userdata.set('expires', int(time() + token_data['expires_in'] - 15)) + + if 'refresh_token' in token_data: + userdata.set('refresh_token', token_data['refresh_token']) + + self._set_authentication() + return True, token_data + + def refresh_token(self): + self._refresh_token() + + def _refresh_token(self, force=False): + if not force and userdata.get('expires', 0) > time() or not self.logged_in: + return + + log.debug('Refreshing token') + + payload = { + 'client_id': CLIENT_ID, + 'refresh_token': userdata.get('refresh_token'), + 'grant_type': 'refresh_token', + 'scope': 'openid offline_access drm:{} email'.format('high' if settings.getBool('wv_secure', False) else 'low'), + } + + self._oauth_token(payload) + + def device_code(self): + payload = { + 'client_id': CLIENT_ID, + 'audience' : 'streamotion.com.au', + 'scope': 'openid offline_access drm:{} email'.format('high' if settings.getBool('wv_secure', False) else 'low'), + } + + return self._session.post('https://auth.streamotion.com.au/oauth/device/code', data=payload).json() + + def device_login(self, device_code): + payload = { + 'client_id': CLIENT_ID, + 'device_code' : device_code, + 'scope': 'openid offline_access drm:{}'.format('high' if settings.getBool('wv_secure', False) else 'low'), + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + } + + result, token_data = self._oauth_token(payload, _raise=False) + if result: + self._refresh_token(force=True) + return True + + if token_data.get('error') != 'authorization_pending': + raise APIError(_(_.LOGIN_ERROR, msg=token_data.get('error_description'))) + else: + return False + + def login(self, username, password): + payload = { + 'client_id': CLIENT_ID, + 'username': username, + 'password': password, + 'audience': 'streamotion.com.au', + 'scope': 'openid offline_access drm:{} email'.format('high' if settings.getBool('wv_secure', False) else 'low'), + 'grant_type': 'http://auth0.com/oauth/grant-type/password-realm', + 'realm': 'prod-martian-database', + } + + self._oauth_token(payload) + self._refresh_token(force=True) + + def profiles(self): + self._refresh_token() + return self._session.get('{host}/user/profile'.format(host=self._config()['endPoints']['profileAPI']), headers=self._auth_header).json() + + def add_profile(self, name, avatar_id): + self._refresh_token() + + payload = { + 'name': name, + 'avatar_id': avatar_id, + 'onboarding_status': 'welcomeScreen', + } + + return self._session.post('{host}/user/profile'.format(host=self._config()['endPoints']['profileAPI']), json=payload, headers=self._auth_header).json() + + def delete_profile(self, profile): + self._refresh_token() + + return self._session.delete('{host}/user/profile/{profile_id}'.format(host=self._config()['endPoints']['profileAPI'], profile_id=profile['id']), headers=self._auth_header) + + def profile_avatars(self): + return self._session.get('{host}/production/avatars/avatars.json'.format(host=self._config()['endPoints']['resourcesAPI'])).json() + + def sport_menu(self): + return self._session.get('{host}/production/sport-menu/lists/default.json'.format(host=self._config()['endPoints']['resourcesAPI'])).json() + + def use_cdn(self, live=False): + return self._session.get('{host}/web/usecdn/unknown/{media}'.format(host=self._config()['endPoints']['cdnSelectionServiceAPI'], media='LIVE' if live else 'VOD'), headers=self._auth_header).json() + + #landing has heros and panels + def landing(self, name, sport=None): + params = { + 'evaluate': 3, + 'profile': userdata.get('profile_id'), + } + + if sport: + params['sport'] = sport + + return self._session.get('{host}/content/types/landing/names/{name}'.format(host=self._config()['endPoints']['contentAPI'], name=name), params=params, headers=self._auth_header).json() + + #panel has shows and episodes + def panel(self, id, sport=None): + params = { + 'evaluate': 3, + 'profile': userdata.get('profile_id'), + } + + if sport: + params['sport'] = sport + + return self._session.get('{host}/content/types/carousel/keys/{id}'.format(host=self._config()['endPoints']['contentAPI'], id=id), params=params, headers=self._auth_header).json()[0] + + #show has episodes and panels + def show(self, show_id, season_id=None): + params = { + 'evaluate': 3, + 'showCategory': show_id, + 'seasonCategory': season_id, + 'profile': userdata.get('profile_id'), + } + + return self._session.get('{host}/content/types/landing/names/show'.format(host=self._config()['endPoints']['contentAPI']), params=params, headers=self._auth_header).json() + + def search(self, query, page=1, size=250): + params = { + 'q': query, + 'size': size, + 'page': page, + } + + return self._session.get('{host}/v2/search'.format(host=self._config()['endPoints']['contentAPI']), params=params).json() + + def event(self, id): + params = { + 'evaluate': 3, + 'event': id, + } + + return self._session.get('{host}/content/types/landing/names/event'.format(host=self._config()['endPoints']['contentAPI']), params=params).json()[0]['contents'][0]['data']['asset'] + + def stream(self, asset_id): + self._refresh_token() + + params = { + 'fields': 'alternativeStreams,assetType,markers,metadata.isStreaming', + } + + data = self._session.post('{host}/api/v1/asset/{asset_id}/play'.format(host=self._config()['endPoints']['vimondPlayAPI'], asset_id=asset_id), params=params, json={}, headers=self._auth_header).json() + if ('status' in data and data['status'] != 200) or 'errors' in data: + msg = data.get('detail') or data.get('errors', [{}])[0].get('detail') + raise APIError(_(_.ASSET_ERROR, msg=msg)) + + return data['data'][0] + + def logout(self): + userdata.delete('access_token') + userdata.delete('refresh_token') + userdata.delete('expires') + self.new_session() \ No newline at end of file diff --git a/plugin.video.kayo.sports/resources/lib/constants.py b/plugin.video.kayo.sports/resources/lib/constants.py new file mode 100644 index 00000000..2e4c2d1e --- /dev/null +++ b/plugin.video.kayo.sports/resources/lib/constants.py @@ -0,0 +1,25 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36', +} + +CONFIG_URL = 'https://resources.kayosports.com.au/production/ios-android-assets/v2/config/metadata.json' +SPORT_LOGO = 'https://resources.kayosports.com.au/production/sport-logos/1x1/{}.png?imwidth=320' +IMG_URL = 'https://vmndims.kayosports.com.au/api/v2/img/{}' +LICENSE_URL = 'https://drm.streamotion.com.au/licenseServer/widevine/v1/streamotion/license' +CHNO_URL = 'https://i.mjh.nz/Kayo/chnos.json' +CLIENT_ID = 'a0n14xap7jreEXPfLo9F6JLpRp3Xeh2l' +CHANNELS_PANEL = 'A35eyiq8Mm' + +FORMAT_HLS_TS = 'hls-ts' +FORMAT_DASH = 'dash' +FORMAT_HLS_FMP4 = 'hls-fmp4' +FORMAT_DRM_DASH = 'drm-dash' +FORMAT_DRM_DASH_HEVC = 'drm-dash-hevc' +CDN_AKAMAI = 'AKAMAI' +CDN_CLOUDFRONT = 'CLOUDFRONT' +CDN_AUTO = 'AUTO' + +AVAILABLE_CDNS = [CDN_AKAMAI, CDN_CLOUDFRONT, CDN_AUTO] +SUPPORTED_FORMATS = [FORMAT_HLS_TS, FORMAT_DASH, FORMAT_HLS_FMP4, FORMAT_DRM_DASH, FORMAT_DRM_DASH_HEVC] + +SERVICE_TIME = 270 \ No newline at end of file diff --git a/plugin.video.kayo.sports/resources/lib/language.py b/plugin.video.kayo.sports/resources/lib/language.py new file mode 100644 index 00000000..9fe0a54c --- /dev/null +++ b/plugin.video.kayo.sports/resources/lib/language.py @@ -0,0 +1,54 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LOGIN_ERROR = 30003 + ASSET_ERROR = 30004 + SELECT_PROFILE = 30005 + ADD_PROFILE = 30006 + SHOWS = 30007 + SPORTS = 30008 + NO_STREAM = 30009 + STARTING_SOON = 30010 + LIVE = 30011 + SELECT_PROFILE = 30013 + SHOW_HERO = 30014 + SET_REMINDER = 30015 + REMOVE_REMINDER = 30016 + REMINDER_SET = 30017 + REMINDER_REMOVED = 30018 + GAME_NOT_STARTED = 30019 + WATCH = 30021 + CLOSE = 30022 + EVENT_STARTED = 30023 + HLS_REQUIRED = 30025 + SEARCH_FOR = 30026 + FEATURED = 30027 + NEXT_PAGE = 30028 + LIVE_CHANNELS = 30029 + DELETE_PROFILE = 30030 + PROFILE_ACTIVATED = 30031 + RANDOM_AVATAR = 30032 + SELECT_AVATAR = 30033 + AVATAR_USED = 30034 + AVATAR_NOT_USED = 30035 + PROFILE_NAME = 30036 + PROFILE_NAME_TAKEN = 30037 + SELECT_DELETE_PROFILE = 30038 + DELTE_PROFILE_HEADER = 30039 + DELETE_PROFILE_INFO = 30040 + PROFILE_DELETED = 30041 + LOGIN_WITH = 30042 + DEVICE_LINK = 30043 + EMAIL_PASSWORD = 30044 + DEVICE_LINK_STEPS = 30045 + WV_SECURE = 30046 + REFRESH_TOKEN_ERROR = 30047 + PREFER_CDN = 30048 + CDN_AKAMAI = 30049 + CDN_CLOUDFRONT = 30050 + TOKEN_ERROR = 30051 + AUTO = 30052 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.kayo.sports/resources/lib/plugin.py b/plugin.video.kayo.sports/resources/lib/plugin.py new file mode 100644 index 00000000..7e856687 --- /dev/null +++ b/plugin.video.kayo.sports/resources/lib/plugin.py @@ -0,0 +1,664 @@ +import codecs +import random +import time + +import arrow +from kodi_six import xbmc + +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.log import log +from slyguy.session import Session +from slyguy.exceptions import PluginError +from slyguy.constants import ROUTE_LIVE_SUFFIX, ROUTE_LIVE_TAG, PLAY_FROM_TYPES, PLAY_FROM_ASK, PLAY_FROM_LIVE, PLAY_FROM_START, ROUTE_RESUME_TAG + +from .api import API, APIError +from .language import _ +from .constants import * + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.FEATURED, _bold=True), path=plugin.url_for(featured)) + folder.add_item(label=_(_.SHOWS, _bold=True), path=plugin.url_for(shows)) + folder.add_item(label=_(_.SPORTS, _bold=True), path=plugin.url_for(sports)) + folder.add_item(label=_(_.LIVE_CHANNELS, _bold=True), path=plugin.url_for(panel, id=CHANNELS_PANEL)) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SELECT_PROFILE, path=plugin.url_for(select_profile), art={'thumb': userdata.get('avatar')}, info={'plot': userdata.get('profile_name')}, _kiosk=False, bookmark=False) + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def login(**kwargs): + if gui.yes_no(_.LOGIN_WITH, yeslabel=_.DEVICE_LINK, nolabel=_.EMAIL_PASSWORD): + result = _device_link() + else: + result = _email_password() + + if not result: + return + + _select_profile() + gui.refresh() + +def _device_link(): + start = time.time() + data = api.device_code() + monitor = xbmc.Monitor() + + with gui.progress(_(_.DEVICE_LINK_STEPS, url=data['verification_uri'], code=data['user_code']), heading=_.DEVICE_LINK) as progress: + while (time.time() - start) < data['expires_in']: + for i in range(data['interval']): + if progress.iscanceled() or monitor.waitForAbort(1): + return + + progress.update(int(((time.time() - start) / data['expires_in']) * 100)) + + if api.device_login(data['device_code']): + return True + +def _email_password(): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + + return True + +@plugin.route() +@plugin.login_required() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + userdata.delete('avatar') + userdata.delete('profile_name') + userdata.delete('profile_id') + gui.refresh() + +@plugin.route() +def featured(**kwargs): + folder = plugin.Folder(_.FEATURED) + folder.add_items(_landing('home')) + return folder + +@plugin.route() +def shows(**kwargs): + folder = plugin.Folder(_.SHOWS) + folder.add_items(_landing('shows')) + return folder + +@plugin.route() +def search(query=None, page=1, **kwargs): + page = int(page) + + if not query: + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + data = api.search(query=query, page=page) + pages = data['pages'] + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query, page=page, total_pages=pages)) + + items = _parse_contents(data.get('results', [])) + folder.add_items(items) + + if pages > page: + folder.add_item( + label = _(_.NEXT_PAGE, next=page+1, total_pages=pages, _bold=True), + path = plugin.url_for(search, query=query, page=page+1), + ) + + return folder + +@plugin.route() +def sports(**kwargs): + folder = plugin.Folder(_.SPORTS) + + for row in api.sport_menu(): + slug = row['url'].split('sport!')[1] + + folder.add_item( + label = row['name'], + path = plugin.url_for(sport, slug=slug, title=row['name']), + art = { + 'thumb': SPORT_LOGO.format(row['sport']), + }, + ) + + folder.add_items(_landing('sports')) + + return folder + +@plugin.route() +def sport(slug, title, **kwargs): + folder = plugin.Folder(title) + folder.add_items(_landing('sport', sport=slug)) + return folder + +@plugin.route() +def season(show_id, season_id, title, **kwargs): + data = api.show(show_id=show_id, season_id=season_id) + folder = plugin.Folder(title) + + for row in data: + if row['title'] == 'Episodes': + folder.add_items(_parse_contents(row.get('contents', []))) + + return folder + +@plugin.route() +def show(show_id, title, **kwargs): + data = api.show(show_id=show_id) + + folder = plugin.Folder(title) + + for row in data: + if row['title'] == 'Seasons': + for row2 in row.get('contents', []): + asset = row2['data']['asset'] + + folder.add_item( + label = asset['title'], + art = { + 'thumb': _get_image(asset, 'show', 'thumb'), + 'fanart': _get_image(asset, 'show', 'fanart'), + }, + info = { + 'plot': asset.get('description-short'), + }, + path = plugin.url_for(season, show_id=show_id, season_id=asset['id'], title=asset['title']), + ) + + return folder + +@plugin.route() +def panel(id, sport=None, **kwargs): + data = api.panel(id, sport=sport) + folder = plugin.Folder(data['title']) + folder.add_items(_parse_contents(data.get('contents', []))) + return folder + +@plugin.route() +def alert(asset, title, **kwargs): + alerts = userdata.get('alerts', []) + + if asset not in alerts: + alerts.append(asset) + gui.notification(title, heading=_.REMINDER_SET) + else: + alerts.remove(asset) + gui.notification(title, heading=_.REMINDER_REMOVED) + + userdata.set('alerts', alerts) + gui.refresh() + +@plugin.route() +@plugin.login_required() +def select_profile(**kwargs): + _select_profile() + gui.refresh() + +def _select_profile(): + profiles = api.profiles() + + options = [] + values = [] + can_delete = [] + default = -1 + + avatars = {} + for avatar in api.profile_avatars(): + avatars[avatar['id']] = avatar['url'] + + for index, profile in enumerate(profiles): + profile['avatar'] = avatars.get(profile['avatar_id']) + + values.append(profile) + options.append(plugin.Item(label=profile['name'], art={'thumb': profile['avatar']})) + + if profile['id'] == userdata.get('profile_id'): + default = index + _set_profile(profile, notify=False) + + elif not profile['root_flag']: + can_delete.append(profile) + + options.append(plugin.Item(label=_(_.ADD_PROFILE, _bold=True))) + values.append('_add') + + if can_delete: + options.append(plugin.Item(label=_(_.DELETE_PROFILE, _bold=True))) + values.append('_delete') + + index = gui.select(_.SELECT_PROFILE, options=options, preselect=default, useDetails=True) + if index < 0: + return + + selected = values[index] + + if selected == '_delete': + _delete_profile(can_delete) + elif selected == '_add': + _add_profile(taken_names=[x['name'].lower() for x in profiles], avatars=avatars, taken_avatars=[x['avatar_id'] for x in profiles]) + else: + _set_profile(selected) + +def _set_profile(profile, notify=True): + userdata.set('avatar', profile['avatar']) + userdata.set('profile_name', profile['name']) + userdata.set('profile_id', profile['id']) + + if notify: + gui.notification(_.PROFILE_ACTIVATED, heading=profile['name'], icon=profile['avatar']) + +def _delete_profile(profiles): + options = [] + for index, profile in enumerate(profiles): + options.append(plugin.Item(label=profile['name'], art={'thumb': profile['avatar']})) + + index = gui.select(_.SELECT_DELETE_PROFILE, options=options, useDetails=True) + if index < 0: + return + + selected = profiles[index] + if gui.yes_no(_.DELETE_PROFILE_INFO, heading=_(_.DELTE_PROFILE_HEADER, name=selected['name'])) and api.delete_profile(selected).ok: + gui.notification(_.PROFILE_DELETED, heading=selected['name'], icon=selected['avatar']) + +def _add_profile(taken_names, avatars, taken_avatars): + ## PROFILE AVATAR ## + options = [plugin.Item(label=_(_.RANDOM_AVATAR, _bold=True)),] + values = ['_random',] + unused = [] + + for avatar_id in avatars: + values.append(avatar_id) + + if avatar_id in taken_avatars: + label = _.AVATAR_USED + else: + label = _.AVATAR_NOT_USED + unused.append(avatar_id) + + options.append(plugin.Item(label=label, art={'thumb': avatars[avatar_id]})) + + index = gui.select(_.SELECT_AVATAR, options=options, useDetails=True) + if index < 0: + return + + avatar_id = values[index] + if avatar_id == '_random': + avatar_id = random.choice(unused or avatars.keys()) + + ## PROFILE NAME ## + name = '' + while True: + name = gui.input(_.PROFILE_NAME, default=name).strip() + if not name: + return + + elif name.lower() in taken_names: + gui.notification(_(_.PROFILE_NAME_TAKEN, name=name)) + + else: + break + + ## ADD PROFILE ## + profile = api.add_profile(name, avatar_id) + profile['avatar'] = avatars[avatar_id] + if 'message' in profile: + raise PluginError(profile['message']) + + _set_profile(profile) + +def _landing(name, sport=None): + items = [] + + for row in api.landing(name, sport=sport): + if row['panelType'] == 'hero-carousel' and row.get('contents') and settings.getBool('show_hero_contents', True): + items.extend(_parse_contents(row['contents'])) + + elif row['panelType'] != 'hero-carousel' and 'id' in row: + items.append(plugin.Item( + label = row['title'], + path = plugin.url_for(panel, id=row['id'], sport=sport), + )) + + return items + +def _parse_contents(rows): + items = [] + + for row in rows: + if row['contentType'] == 'video': + items.append(_parse_video(row['data'])) + + elif row['contentType'] == 'section': + items.append(_parse_section(row['data'])) + + return items + +def _parse_section(row): + # If not asset, we are probably linking directly to a sport or something.. + if 'asset' not in row: + return + + asset = row['asset'] + + return plugin.Item( + label = asset['title'], + art = { + 'thumb': _get_image(asset, 'show', 'thumb'), + 'fanart': _get_image(asset, 'show', 'fanart'), + }, + info = { + 'plot': asset.get('description-short'), + }, + path = plugin.url_for(show, show_id=asset['id'], title=asset['title']), + ) + +def _get_image(asset, media_type, img_type='thumb', width=None): + if not asset.get('image-pack'): + images = asset.get('images') or {} + image_url = images.get('defaultUrl') + if not image_url: + return None + else: + image_url = IMG_URL.format(asset['image-pack']) + + image_url += '?location={}&imwidth={}' + + if img_type == 'thumb': + return image_url.format('carousel-item', width or 415) + + elif img_type == 'fanart': + return image_url.format('hero-default', width or 1920) + +def _makeTime(start=None): + return start.to('local').format('h:mmA') if start else '' + +def _makeDate(now, start=None): + if not start: + return '' + + if now.year == start.year: + return start.to('local').format('DD MMM') + else: + return start.to('local').format('DD MMM YY') + +# function makeDuration(e) { +# var t = e.nowTimeDate, +# a = e.startTimeDate, +# r = e.endTimeDate; +# if (!(0, _is_valid2.default)(a)) return ""; +# if (!(0, _is_valid2.default)(r)) return makeDate({ +# nowTimeDate: t, +# startTimeDate: a +# }); +# var i = (0, _dateFns.format)(t, "YYYY") === (0, _dateFns.format)(r, "YYYY"), +# n = (0, _dateFns.format)(a, "D"), +# o = (0, _dateFns.format)(a, "MMM"), +# s = (0, _dateFns.format)(r, "D"), +# d = (0, _dateFns.format)(r, "MMM"), +# u = (0, _dateFns.format)(r, "YYYY"); +# return n === s && o === d ? n + " " + o : n !== s && o === d ? n + "-" + s + " " + o : i ? n + " " + o + " - " + s + " " + d : n + " " + o + " - " + s + " " + d + " " + u +# } +# def _makeDuration(now, start=None, end=None): +# if not start: +# return '' + +# if not end: +# return _makeDate(now, start) + +def _makeHumanised(now, start=None): + if not start: + return '' + + now = now.to('local').replace(hour = 0, minute = 0, second = 0, microsecond = 0) + start = start.to('local').replace(hour = 0, minute = 0, second = 0, microsecond = 0) + days = (start - now).days + + if days == -1: + return 'yesterday' + elif days == 0: + return 'today' + elif days == 1: + return 'tomorrow' + elif days <= 7 and days >= 1: + return start.format('dddd') + else: + return _makeDate(now, start) + +def _parse_video(row): + asset = row['asset'] + display = row['contentDisplay'] + alerts = userdata.get('alerts', []) + + now = arrow.now() + start = arrow.get(asset['transmissionTime']) + precheck = start + + if 'preCheckTime' in asset: + precheck = arrow.get(asset['preCheckTime']) + if precheck > start: + precheck = start + + title = display['title'] or asset['title'] + if 'heroHeader' in display: + title += ' [' + display['heroHeader'].replace('${DATE_HUMANISED}', _makeHumanised(now, start).upper()).replace('${TIME}', _makeTime(start)) + ']' + + item = plugin.Item( + label = title, + art = { + 'thumb' : _get_image(asset, 'video', 'thumb'), + 'fanart': _get_image(asset, 'video', 'fanart'), + }, + info = { + 'plot': display.get('description'), + 'plotoutline': display.get('description'), + 'mediatype': 'video', + }, + playable = True, + is_folder = False, + ) + + is_live = False + play_type = settings.getEnum('live_play_type', PLAY_FROM_TYPES, default=PLAY_FROM_ASK) + start_from = ((start - precheck).seconds) + + if start_from < 1: + start_from = 1 + + if now < start: + is_live = True + toggle_alert = plugin.url_for(alert, asset=asset['id'], title=asset['title']) + + if asset['id'] not in userdata.get('alerts', []): + item.info['playcount'] = 0 + item.context.append((_.SET_REMINDER, "RunPlugin({})".format(toggle_alert))) + else: + item.info['playcount'] = 1 + item.context.append((_.REMOVE_REMINDER, "RunPlugin({})".format(toggle_alert))) + + elif asset['assetType'] == 'live-linear': + is_live = True + start_from = 0 + play_type = PLAY_FROM_LIVE + + elif asset['isLive'] and asset.get('isStreaming', False): + is_live = True + + item.context.append((_.PLAY_FROM_LIVE, "PlayMedia({})".format( + plugin.url_for(play, id=asset['id'], play_type=PLAY_FROM_LIVE, _is_live=is_live) + ))) + + item.context.append((_.PLAY_FROM_START, "PlayMedia({})".format( + plugin.url_for(play, id=asset['id'], start_from=start_from, play_type=PLAY_FROM_START, _is_live=is_live) + ))) + + item.path = plugin.url_for(play, id=asset['id'], start_from=start_from, play_type=play_type, _is_live=is_live) + + return item + +@plugin.route() +@plugin.login_required() +def play(id, start_from=0, play_type=PLAY_FROM_LIVE, **kwargs): + asset = api.stream(id) + + start_from = int(start_from) + play_type = int(play_type) + is_live = kwargs.get(ROUTE_LIVE_TAG) == ROUTE_LIVE_SUFFIX + + streams = [asset['recommendedStream']] + streams.extend(asset['alternativeStreams']) + streams = [s for s in streams if s['mediaFormat'] in SUPPORTED_FORMATS] + + if not streams: + raise PluginError(_.NO_STREAM) + + prefer_cdn = settings.getEnum('prefer_cdn', AVAILABLE_CDNS) + if prefer_cdn == CDN_AUTO: + try: + prefer_cdn = api.use_cdn(is_live)['useCDN'] + except Exception as e: + log.debug('Failed to get preferred cdn') + prefer_cdn = None + + providers = [prefer_cdn] + providers.extend([s['provider'] for s in streams]) + + streams = sorted(streams, key=lambda k: (providers.index(k['provider']), SUPPORTED_FORMATS.index(k['mediaFormat']))) + stream = streams[0] + + log.debug('Stream CDN: {provider} | Stream Format: {mediaFormat}'.format(**stream)) + + item = plugin.Item( + path = stream['manifest']['uri'], + art = False, + headers = HEADERS, + ) + + item.headers.update({'authorization': 'Bearer {}'.format(userdata.get('access_token'))}) + + if is_live and (play_type == PLAY_FROM_LIVE or (play_type == PLAY_FROM_ASK and gui.yes_no(_.PLAY_FROM, yeslabel=_.PLAY_FROM_LIVE, nolabel=_.PLAY_FROM_START))): + play_type = PLAY_FROM_LIVE + start_from = 0 + + ## Cloudfront streams start from correct position + if stream['provider'] == CDN_CLOUDFRONT and start_from: + start_from = 1 + + if stream['mediaFormat'] == FORMAT_DASH: + item.inputstream = inputstream.MPD() + + elif stream['mediaFormat'] == FORMAT_HLS_TS: + force = (is_live and play_type == PLAY_FROM_LIVE and asset['assetType'] != 'live-linear') + item.inputstream = inputstream.HLS(force=force, live=is_live) + if force and not item.inputstream.check(): + raise PluginError(_.HLS_REQUIRED) + + elif stream['mediaFormat'] == FORMAT_HLS_FMP4: + item.inputstream = inputstream.HLS(force=True, live=is_live) + if not item.inputstream.check(): + raise PluginError(_.HLS_REQUIRED) + + elif stream['mediaFormat'] in (FORMAT_DRM_DASH, FORMAT_DRM_DASH_HEVC): + item.inputstream = inputstream.Widevine( + license_key = LICENSE_URL, + ) + + if start_from and not kwargs[ROUTE_RESUME_TAG]: + item.properties['ResumeTime'] = start_from + item.properties['TotalTime'] = start_from + + return item + +@signals.on(signals.ON_SERVICE) +def service(): + alerts = userdata.get('alerts', []) + if not alerts: + return + + now = arrow.now() + notify = [] + _alerts = [] + + for id in alerts: + asset = api.event(id) + start = arrow.get(asset.get('preCheckTime', asset['transmissionTime'])) + + #If we are streaming and started less than 10 minutes ago + if asset.get('isStreaming', False) and (now - start).total_seconds() <= 60*10: + notify.append(asset) + elif start > now: + _alerts.append(id) + + userdata.set('alerts', _alerts) + + for asset in notify: + if not gui.yes_no(_(_.EVENT_STARTED, event=asset['title']), yeslabel=_.WATCH, nolabel=_.CLOSE): + continue + + with signals.throwable(): + start_from = 1 + start = arrow.get(asset['transmissionTime']) + + if start < now and 'preCheckTime' in asset: + precheck = arrow.get(asset['preCheckTime']) + if precheck < start: + start_from = (start - precheck).seconds + + play(id=asset['id'], start_from=start_from, play_type=settings.getEnum('live_play_type', LIVE_PLAY_TYPES, default=FROM_CHOOSE)) + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + data = api.panel(CHANNELS_PANEL) + + try: chnos = Session().get(CHNO_URL).json() + except: chnos = {} + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for row in data.get('contents', []): + asset = row['data']['asset'] + + if row['contentType'] != 'video': + continue + + chid = asset['id'] + chno = chnos.get(chid) or '' + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{channel}" channel-id="{channel}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=chid, channel=chno, logo=_get_image(asset, 'video', 'thumb'), + name=asset['title'], path=plugin.url_for(play, id=chid, play_type=PLAY_FROM_START, _is_live=True))) \ No newline at end of file diff --git a/plugin.video.kayo.sports/resources/settings.xml b/plugin.video.kayo.sports/resources/settings.xml new file mode 100644 index 00000000..cdadc9df --- /dev/null +++ b/plugin.video.kayo.sports/resources/settings.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.kayo.sports/service.py b/plugin.video.kayo.sports/service.py new file mode 100644 index 00000000..ef90c79f --- /dev/null +++ b/plugin.video.kayo.sports/service.py @@ -0,0 +1,5 @@ +from slyguy.service import run + +from resources.lib.constants import SERVICE_TIME + +run(SERVICE_TIME) \ No newline at end of file diff --git a/plugin.video.nz.films/__init__.py b/plugin.video.nz.films/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.nz.films/addon.xml b/plugin.video.nz.films/addon.xml new file mode 100644 index 00000000..b00919b6 --- /dev/null +++ b/plugin.video.nz.films/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Watch your favourite content from NZ Film On Demand. + +Subscription required + true + + + + Add Bookmarks + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.nz.films/default.py b/plugin.video.nz.films/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.nz.films/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.nz.films/fanart.jpg b/plugin.video.nz.films/fanart.jpg new file mode 100644 index 00000000..885203de Binary files /dev/null and b/plugin.video.nz.films/fanart.jpg differ diff --git a/plugin.video.nz.films/icon.png b/plugin.video.nz.films/icon.png new file mode 100644 index 00000000..84f55141 Binary files /dev/null and b/plugin.video.nz.films/icon.png differ diff --git a/plugin.video.nz.films/resources/__init__.py b/plugin.video.nz.films/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.nz.films/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.nz.films/resources/language/resource.language.en_gb/strings.po b/plugin.video.nz.films/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..e8d0849d --- /dev/null +++ b/plugin.video.nz.films/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,108 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "My Library" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32034" +msgid "General" +msgstr "" + +msgctxt "#32035" +msgid "Playback" +msgstr "" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "" + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "" + +msgctxt "#32023" +msgid "Use Inputstream HLS" +msgstr "" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "" + +msgctxt "#32036" +msgid "Advanced" +msgstr "" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "" + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" diff --git a/plugin.video.nz.films/resources/lib/__init__.py b/plugin.video.nz.films/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.nz.films/resources/lib/api.py b/plugin.video.nz.films/resources/lib/api.py new file mode 100644 index 00000000..b0b3c1e9 --- /dev/null +++ b/plugin.video.nz.films/resources/lib/api.py @@ -0,0 +1,79 @@ +from slyguy import userdata, inputstream, plugin +from slyguy.session import Session +from slyguy.exceptions import Error + +from .constants import HEADERS, BASE_URL +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + + self._session = Session(HEADERS, base_url=BASE_URL) + self.set_authentication() + + def set_authentication(self): + token = userdata.get('auth_token') + if not token: + return + + self._session.headers.update({'x-auth-token': token}) + self.logged_in = True + + def login(self, username, password): + self.logout() + + data = {'user': { + 'email': username, + 'password': password, + 'remember_me': True + }} + + data = self._session.post('/services/users/auth/sign_in', json=data).json() + if 'error' in data: + raise APIError(data['error']) + + auth_token = data['auth_token'] + user_id = data['account']['user_id'] + + userdata.set('auth_token', auth_token) + userdata.set('user_id', user_id) + + self.set_authentication() + + def my_library(self): + meta = {} + + items = self._session.get('/services/content/v3/user_library/{}/index'.format(userdata.get('user_id')), params={'sort_by': 'relevance'}).json() + _meta = self._session.get('/services/meta/v2/film/{}/show_multiple'.format(','.join(str(x['info']['film_id']) for x in items))).json() + for item in _meta: + meta[item['film_id']] = item + + for item in items: + item['meta'] = meta.get(item['info']['film_id'], {}) + + return items + + def get_stream(self, film_id): + play_data = self._session.get('/services/content/v4/media_content/play/film/{}'.format(film_id), params={'encoding_type':'dash', 'drm':'widevine'}).json() + if 'error' in play_data: + raise APIError(play_data['error']) + + mpd_url = play_data['streams'][0]['url'] + key_url = BASE_URL.format('/services/license/widevine/cenc?context={}'.format(play_data['streams'][0]['drm_key_encoded'].strip())) + + item = plugin.Item( + path = play_data['streams'][0]['url'], + inputstream = inputstream.Widevine(license_key=key_url), + headers = self._session.headers, + ) + + return item + + def logout(self): + userdata.delete('auth_token') + userdata.delete('user_id') + self.new_session() \ No newline at end of file diff --git a/plugin.video.nz.films/resources/lib/constants.py b/plugin.video.nz.films/resources/lib/constants.py new file mode 100644 index 00000000..dd0aaf36 --- /dev/null +++ b/plugin.video.nz.films/resources/lib/constants.py @@ -0,0 +1,5 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36', +} + +BASE_URL = 'https://ondemand.nzfilm.co.nz{}' \ No newline at end of file diff --git a/plugin.video.nz.films/resources/lib/language.py b/plugin.video.nz.films/resources/lib/language.py new file mode 100644 index 00000000..70795d04 --- /dev/null +++ b/plugin.video.nz.films/resources/lib/language.py @@ -0,0 +1,8 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + MY_LIBRARY = 30003 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.nz.films/resources/lib/plugin.py b/plugin.video.nz.films/resources/lib/plugin.py new file mode 100644 index 00000000..70e4b064 --- /dev/null +++ b/plugin.video.nz.films/resources/lib/plugin.py @@ -0,0 +1,82 @@ +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.log import log + +from .api import API +from .language import _ + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.MY_LIBRARY, _bold=True), path=plugin.url_for(my_library)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +@plugin.login_required() +def my_library(**kwargs): + folder = plugin.Folder(_.MY_LIBRARY) + + for row in api.my_library(): + item = plugin.Item( + label = row['meta']['title'], + path = plugin.url_for(play, film_id=row['meta']['film_id']), + art = {'thumb': row['meta']['image_urls']['portrait']}, + info = {'plot': row['meta']['overview'], 'mediatype': 'movie'}, + playable = True, + ) + + try: + if row['meta']['trailers']: + item.info['trailer'] = 'plugin://plugin.video.youtube/?action=play_video&videoid={}'.format(row['meta']['trailers'][0]['url'].split('?v=')[1]) + except: + pass + + folder.add_items([item]) + + return folder + +@plugin.route() +@plugin.login_required() +def play(film_id, **kwargs): + return api.get_stream(film_id) + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() \ No newline at end of file diff --git a/plugin.video.nz.films/resources/settings.xml b/plugin.video.nz.films/resources/settings.xml new file mode 100644 index 00000000..9dbcfcfa --- /dev/null +++ b/plugin.video.nz.films/resources/settings.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.nz.freeview/.iptv_merge b/plugin.video.nz.freeview/.iptv_merge new file mode 100644 index 00000000..4366827b --- /dev/null +++ b/plugin.video.nz.freeview/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/nz/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.nz.freeview/__init__.py b/plugin.video.nz.freeview/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.nz.freeview/addon.xml b/plugin.video.nz.freeview/addon.xml new file mode 100644 index 00000000..42c6aded --- /dev/null +++ b/plugin.video.nz.freeview/addon.xml @@ -0,0 +1,21 @@ + + + + + + + video + + + Easily watch your favourite NZ IPTV streams + true + + + + Add Bookmarks. Re-arrange menu + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.nz.freeview/default.py b/plugin.video.nz.freeview/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.nz.freeview/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.nz.freeview/fanart.jpg b/plugin.video.nz.freeview/fanart.jpg new file mode 100644 index 00000000..f313d162 Binary files /dev/null and b/plugin.video.nz.freeview/fanart.jpg differ diff --git a/plugin.video.nz.freeview/icon.png b/plugin.video.nz.freeview/icon.png new file mode 100644 index 00000000..046354de Binary files /dev/null and b/plugin.video.nz.freeview/icon.png differ diff --git a/plugin.video.nz.freeview/resources/__init__.py b/plugin.video.nz.freeview/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.nz.freeview/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.nz.freeview/resources/language/resource.language.en_gb/strings.po b/plugin.video.nz.freeview/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..ca5ec6f7 --- /dev/null +++ b/plugin.video.nz.freeview/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,32 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Live TV" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.nz.freeview/resources/lib/__init__.py b/plugin.video.nz.freeview/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.nz.freeview/resources/lib/constants.py b/plugin.video.nz.freeview/resources/lib/constants.py new file mode 100644 index 00000000..19dcfade --- /dev/null +++ b/plugin.video.nz.freeview/resources/lib/constants.py @@ -0,0 +1 @@ +M3U8_URL = 'https://i.mjh.nz/nz/tv.json.gz' \ No newline at end of file diff --git a/plugin.video.nz.freeview/resources/lib/language.py b/plugin.video.nz.freeview/resources/lib/language.py new file mode 100644 index 00000000..26160271 --- /dev/null +++ b/plugin.video.nz.freeview/resources/lib/language.py @@ -0,0 +1,6 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + LIVE_TV = 30000 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.nz.freeview/resources/lib/plugin.py b/plugin.video.nz.freeview/resources/lib/plugin.py new file mode 100644 index 00000000..1ef3d550 --- /dev/null +++ b/plugin.video.nz.freeview/resources/lib/plugin.py @@ -0,0 +1,81 @@ +import codecs + +from slyguy import plugin, inputstream, settings +from slyguy.session import Session +from slyguy.mem_cache import cached + +from .language import _ +from .constants import M3U8_URL + +session = Session() + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + folder.add_item(label=_(_.LIVE_TV, _bold=True), path=plugin.url_for(live_tv)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def live_tv(**kwargs): + folder = plugin.Folder(_.LIVE_TV) + + channels = get_channels() + for slug in sorted(channels, key=lambda k: (float(channels[k].get('channel', 'inf')), channels[k]['name'])): + channel = channels[slug] + + folder.add_item( + label = channel['name'], + path = plugin.url_for(play, slug=slug, _is_live=True), + info = {'plot': channel.get('description')}, + video = channel.get('video', {}), + audio = channel.get('audio', {}), + art = {'thumb': channel.get('logo')}, + playable = True, + ) + + return folder + +@plugin.route() +def play(slug, **kwargs): + channel = get_channels()[slug] + url = session.head(channel['mjh_master']).headers.get('location', '') + + item = plugin.Item( + path = url or channel['mjh_master'], + headers = channel['headers'], + info = {'plot': channel.get('description')}, + video = channel.get('video', {}), + audio = channel.get('audio', {}), + art = {'thumb': channel.get('logo')}, + ) + + if channel.get('hls', False): + item.inputstream = inputstream.HLS(live=True) + + return item + +@cached(60*5) +def get_channels(): + return session.gz_json(M3U8_URL) + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + channels = get_channels() + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for slug in sorted(channels, key=lambda k: (float(channels[k].get('channel', 'inf')), channels[k]['name'])): + channel = channels[slug] + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{chno}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=slug, logo=channel.get('logo', ''), name=channel['name'], chno=channel.get('channel', ''), + path=plugin.url_for(play, slug=slug, _is_live=True))) \ No newline at end of file diff --git a/plugin.video.nz.freeview/resources/settings.xml b/plugin.video.nz.freeview/resources/settings.xml new file mode 100644 index 00000000..242ad062 --- /dev/null +++ b/plugin.video.nz.freeview/resources/settings.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.optus.sport/.iptv_merge b/plugin.video.optus.sport/.iptv_merge new file mode 100644 index 00000000..3d224021 --- /dev/null +++ b/plugin.video.optus.sport/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/Optus/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.optus.sport/__init__.py b/plugin.video.optus.sport/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.optus.sport/addon.xml b/plugin.video.optus.sport/addon.xml new file mode 100644 index 00000000..c271fa31 --- /dev/null +++ b/plugin.video.optus.sport/addon.xml @@ -0,0 +1,24 @@ + + + + + + + video + + + + Watch your favourite sports live & on demand with Optus Sport. + +Subscription required. + true + + + + Add Bookmarks + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.optus.sport/default.py b/plugin.video.optus.sport/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.optus.sport/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.optus.sport/fanart.jpg b/plugin.video.optus.sport/fanart.jpg new file mode 100644 index 00000000..b65c757e Binary files /dev/null and b/plugin.video.optus.sport/fanart.jpg differ diff --git a/plugin.video.optus.sport/icon.png b/plugin.video.optus.sport/icon.png new file mode 100644 index 00000000..bac17616 Binary files /dev/null and b/plugin.video.optus.sport/icon.png differ diff --git a/plugin.video.optus.sport/resources/__init__.py b/plugin.video.optus.sport/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.optus.sport/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.optus.sport/resources/language/resource.language.en_gb/strings.po b/plugin.video.optus.sport/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..33129034 --- /dev/null +++ b/plugin.video.optus.sport/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,98 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Failed to login.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30004" +msgid "Channels" +msgstr "" + +msgctxt "#30005" +msgid "Featured" +msgstr "" + +msgctxt "#30006" +msgid "Your IP is being blocked from accessing this asset.\n" +"An Australian IP address is required & some VPNs are also blocked" +msgstr "" + +msgctxt "#30007" +msgid "Unable to find a stream for this assset" +msgstr "" + +msgctxt "#30008" +msgid "{label} (LIVE)" +msgstr "" + +msgctxt "#30009" +msgid " (ddd MMM D, h:mm A)" +msgstr "" + +msgctxt "#30010" +msgid "Reminder Set" +msgstr "" + +msgctxt "#30011" +msgid "Reminder Removed" +msgstr "" + +msgctxt "#30012" +msgid "{event} has just started" +msgstr "" + +msgctxt "#30013" +msgid "Watch" +msgstr "" + +msgctxt "#30014" +msgid "Close" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" diff --git a/plugin.video.optus.sport/resources/lib/__init__.py b/plugin.video.optus.sport/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.optus.sport/resources/lib/api.py b/plugin.video.optus.sport/resources/lib/api.py new file mode 100644 index 00000000..9f2c7d8e --- /dev/null +++ b/plugin.video.optus.sport/resources/lib/api.py @@ -0,0 +1,132 @@ +import time + +from slyguy import userdata +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.util import jwt_data + +from .constants import HEADERS, API_URL, AWS_URL, AWS_CLIENT_ID +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + + self._session = Session(HEADERS, base_url=API_URL) + self._set_authentication() + + def _set_authentication(self): + id_token = userdata.get('id_token') + if not id_token: + return + + self._session.headers.update({'Authorization': id_token}) + self.logged_in = True + + def navigation(self): + return self._session.get('/metadata/navigations/nav_v1').json()['navigations'] + + def page(self, name): + data = self._session.get('/metadata/pages/{}'.format(name)).json() + return [x for x in data['panels'] if 'title' in x] + + def editorial(self, name): + data = self._session.get('/metadata/editorials/v2/{}/mobile'.format(name)).json() + return data['assets'] + + def asset(self, id): + return self._session.get('/metadata/assets/v2/{}/mobile'.format(id)).json() + + def login(self, username, password): + self.logout() + + payload = { + "username": username, + "password": password, + "rememberMe": "false" + } + + r = self._session.post('/userauth/login', json=payload) + + if not r.ok: + if r.status_code == 403: + raise APIError(_.GEO_BLOCKED) + else: + raise APIError(_(_.LOGIN_ERROR, msg=r.json()['error'].get('description'))) + + data = r.json() + userdata.set('user_id', data['userId']) + self._parse_token(data['result']) + + def _parse_token(self, data): + userdata.set('id_token', data['IdToken']) + userdata.set('expires', int(time.time() + data['ExpiresIn'] - 15)) + + if 'RefreshToken' in data: + userdata.set('refresh_token', data['RefreshToken']) + + self._set_authentication() + + def _check_token(self): + if userdata.get('expires') > time.time(): + return + + headers = { + 'X-Amz-Target': 'AWSCognitoIdentityProviderService.InitiateAuth', + 'X-Amz-User-Agent': 'aws-amplify/0.1.x js', + 'Content-Type': 'application/x-amz-json-1.1', + } + + payload = { + 'AuthFlow': 'REFRESH_TOKEN_AUTH', + 'AuthParameters': { + 'DEVICE_KEY': None, + 'REFRESH_TOKEN': userdata.get('refresh_token'), + }, + 'ClientId': AWS_CLIENT_ID, + } + + r = self._session.post(AWS_URL, json=payload, headers=headers) + data = r.json() + if 'message' in data: + raise APIError(_(_.LOGIN_ERROR, msg=data['message'])) + + self._parse_token(data['AuthenticationResult']) + + def play(self, asset, from_start=False): + self._check_token() + + params = { + 'type': 'dash', + 'drm': 'widevine', + 'watchMode': 'startover' if from_start else 'live', + } + + r = self._session.get('/playback/generalPlayback/mobile/users/{user_id}/assets/{asset_id}'.format(user_id=userdata.get('user_id'), asset_id=asset), params=params) + + if not r.ok: + if r.status_code == 403: + raise APIError(_.GEO_BLOCKED) + else: + raise APIError(r.json()['error'].get('description')) + + data = r.json() + + stream = data['playback']['items']['item'] + if type(stream) is list: + stream = stream[0] + + if not stream: + raise APIError(_.NO_STREAM) + + return stream + + def logout(self): + userdata.delete('user_id') + userdata.delete('id_token') + userdata.delete('refresh_token') + userdata.delete('expires') + self.new_session() \ No newline at end of file diff --git a/plugin.video.optus.sport/resources/lib/constants.py b/plugin.video.optus.sport/resources/lib/constants.py new file mode 100644 index 00000000..7290737a --- /dev/null +++ b/plugin.video.optus.sport/resources/lib/constants.py @@ -0,0 +1,27 @@ +from .language import _ + +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36', + 'Origin': 'https://sport.optus.com.au', + 'Referer': 'https://sport.optus.com.au', +} + +API_URL = 'https://sport.optus.com.au/api{}' +AWS_URL = 'https://cognito-idp.ap-southeast-2.amazonaws.com/' +AWS_CLIENT_ID = '4f9j45k3024143l8hck1k1o2tp' +SERVICE_TIME = 270 +DEFAULT_IMG = 'https://sport.optus.com.au/images/OS_LOGO_YES_NAVY_4K_640_360.jpg' +LINEAR_ID = 'home_linear_channels' + +# CHANNELS = [ +# ['v42463', _.OPTUS_1, 'OPTSP01', 'https://i.imgur.com/7YIRcp7.png'], +# #['v42611', _.OPTUS_2, 'OPTSP02', 'https://i.imgur.com/TLZsoTn.png'], +# # ['42823', _.OPTUS_3, 'OPTSP03', 'https://i.imgur.com/1aK5qdV.png'], +# # ['v30004', _.OPTUS_4, 'OPTSP04', 'https://i.imgur.com/4zurIBa.png'], +# # ['v30005', _.OPTUS_5, 'OPTSP05', 'https://i.imgur.com/wyXPXan.png'], +# # ['v30006', _.OPTUS_6, 'OPTSP06', 'https://i.imgur.com/fGkjhJn.png'], +# # ['v30007', _.OPTUS_7, 'OPTSP07', 'https://i.imgur.com/qGl9emd.png'], +# # ['v30008', _.OPTUS_8, 'OPTSP08', 'https://i.imgur.com/KuYUL4v.png'], +# # ['v42699', _.OPTUS_9, 'OPTSP09', 'https://i.imgur.com/KuYUL4v.png'], +# # ['v42735', _.OPTUS_10, 'OPTSP10', 'https://i.imgur.com/KuYUL4v.png'], +# ] \ No newline at end of file diff --git a/plugin.video.optus.sport/resources/lib/language.py b/plugin.video.optus.sport/resources/lib/language.py new file mode 100644 index 00000000..b822f9b7 --- /dev/null +++ b/plugin.video.optus.sport/resources/lib/language.py @@ -0,0 +1,19 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LOGIN_ERROR = 30003 + CHANNELS = 30004 + FEATURED = 30005 + GEO_BLOCKED = 30006 + NO_STREAM = 30007 + LIVE = 30008 + DATE_FORMAT = 30009 + REMINDER_SET = 30010 + REMINDER_REMOVED = 30011 + EVENT_STARTED = 30012 + WATCH = 30013 + CLOSE = 30014 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.optus.sport/resources/lib/plugin.py b/plugin.video.optus.sport/resources/lib/plugin.py new file mode 100644 index 00000000..7fe6d799 --- /dev/null +++ b/plugin.video.optus.sport/resources/lib/plugin.py @@ -0,0 +1,229 @@ +import codecs + +import arrow + +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.constants import PLAY_FROM_TYPES, PLAY_FROM_ASK, PLAY_FROM_LIVE, PLAY_FROM_START, ROUTE_LIVE_TAG, ROUTE_LIVE_SUFFIX + +from .api import API +from .language import _ +from .constants import HEADERS, DEFAULT_IMG, LINEAR_ID + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.CHANNELS, _bold=True), path=plugin.url_for(editorial, id=LINEAR_ID, title=_.CHANNELS)) + _home(folder) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +def _home(folder): + for row in api.navigation(): + if row['id'] == 'teams': + continue + + if row['id'] == 'home': + row['title'] = _.FEATURED + + folder.add_item( + label = _(row['title'], _bold=True), + path = plugin.url_for(page, id=row['path'], title=row['title']), + ) + +@plugin.route() +def page(id, title, **kwargs): + folder = plugin.Folder(title) + + for row in api.page(id): + folder.add_item( + label = row['title'], + path = plugin.url_for(editorial, id=row['id'], title=row['title']), + ) + + return folder + +@plugin.route() +def editorial(id, title, **kwargs): + folder = plugin.Folder(title) + + alerts = userdata.get('alerts', []) + now = arrow.utcnow() + + live_play_type = settings.getEnum('live_play_type', PLAY_FROM_TYPES, default=PLAY_FROM_ASK) + + for row in api.editorial(id): + is_live = row.get('isLive', False) + is_linear = row.get('type') == 'linear-channel' + + item = plugin.Item( + label = row['title'], + info = { + 'plot': row.get('description'), + 'duration': row.get('duration', 0), + }, + art = {'thumb': row.get('imageUrl') or DEFAULT_IMG}, + path = plugin.url_for(play, asset=row['id'], _is_live=is_live), + playable = True, + is_folder = False, + ) + + start_time = arrow.get(row['broadcastStartTime']) if 'broadcastStartTime' in row else None + + if start_time and start_time > now: + item.label += start_time.to('local').format(_.DATE_FORMAT) + item.path = plugin.url_for(alert, asset=row['id'], title=row['title']) + item.playable = False + + if row['id'] not in alerts: + item.info['playcount'] = 0 + else: + item.info['playcount'] = 1 + + elif is_linear: + item.path = plugin.url_for(play, asset=row['id'], _is_live=is_live) + + elif is_live: + item.label = _(_.LIVE, label=item.label) + + item.context.append((_.PLAY_FROM_LIVE, "PlayMedia({})".format( + plugin.url_for(play, asset=row['id'], play_type=PLAY_FROM_LIVE, _is_live=is_live) + ))) + + item.context.append((_.PLAY_FROM_START, "PlayMedia({})".format( + plugin.url_for(play, asset=row['id'], play_type=PLAY_FROM_START, _is_live=is_live) + ))) + + item.path = plugin.url_for(play, asset=row['id'], play_type=live_play_type, _is_live=is_live) + + folder.add_items(item) + + return folder + +@plugin.route() +def alert(asset, title, **kwargs): + alerts = userdata.get('alerts', []) + + if asset not in alerts: + alerts.append(asset) + gui.notification(title, heading=_.REMINDER_SET) + else: + alerts.remove(asset) + gui.notification(title, heading=_.REMINDER_REMOVED) + + userdata.set('alerts', alerts) + gui.refresh() + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +@plugin.login_required() +def play(asset, play_type=PLAY_FROM_LIVE, **kwargs): + play_type = int(play_type) + + from_start = False + if play_type == PLAY_FROM_START or (play_type == PLAY_FROM_ASK and not gui.yes_no(_.PLAY_FROM, yeslabel=_.PLAY_FROM_LIVE, nolabel=_.PLAY_FROM_START)): + from_start = True + + stream = api.play(asset, True) + + item = plugin.Item( + path = stream['url'], + inputstream = inputstream.Widevine(license_key=stream['license']['@uri']), + headers = HEADERS, + ) + + drm_data = stream['license'].get('drmData') + if drm_data: + item.headers['x-axdrm-message'] = drm_data + + if from_start: + item.properties['ResumeTime'] = '1' + item.properties['TotalTime'] = '1' + + if kwargs.get(ROUTE_LIVE_TAG): + item.inputstream.properties['manifest_update_parameter'] = 'full' + + return item + +@signals.on(signals.ON_SERVICE) +def service(): + alerts = userdata.get('alerts', []) + if not alerts: + return + + now = arrow.now() + notify = [] + _alerts = [] + + for id in alerts: + asset = api.asset(id) + if 'broadcastStartTime' not in asset: + continue + + start = arrow.get(asset['broadcastStartTime']) + + if now > start and (now - start).total_seconds() <= 60*10: + notify.append(asset) + elif now < start: + _alerts.append(id) + + userdata.set('alerts', _alerts) + + for asset in notify: + if not gui.yes_no(_(_.EVENT_STARTED, event=asset['title']), yeslabel=_.WATCH, nolabel=_.CLOSE): + continue + + with signals.throwable(): + play(asset['id']) + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for row in api.editorial(LINEAR_ID): + if row.get('type') != 'linear-channel': + continue + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=row['channel']['id'], logo=row.get('imageUrl') or DEFAULT_IMG, name=row['title'], path=plugin.url_for(play, asset=row['id'], _is_live=True))) \ No newline at end of file diff --git a/plugin.video.optus.sport/resources/settings.xml b/plugin.video.optus.sport/resources/settings.xml new file mode 100644 index 00000000..7b766e5c --- /dev/null +++ b/plugin.video.optus.sport/resources/settings.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.optus.sport/service.py b/plugin.video.optus.sport/service.py new file mode 100644 index 00000000..ef90c79f --- /dev/null +++ b/plugin.video.optus.sport/service.py @@ -0,0 +1,5 @@ +from slyguy.service import run + +from resources.lib.constants import SERVICE_TIME + +run(SERVICE_TIME) \ No newline at end of file diff --git a/plugin.video.play.stuff/__init__.py b/plugin.video.play.stuff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.play.stuff/addon.xml b/plugin.video.play.stuff/addon.xml new file mode 100644 index 00000000..90760b9d --- /dev/null +++ b/plugin.video.play.stuff/addon.xml @@ -0,0 +1,24 @@ + + + + + + + video + + + Play Stuff is an innovative new video on demand product that will appeal to a broad audience, including younger viewers. + +Requires New Zealand IP Address + + true + + + + Fix shows (now called Series) + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.play.stuff/default.py b/plugin.video.play.stuff/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.play.stuff/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.play.stuff/fanart.jpg b/plugin.video.play.stuff/fanart.jpg new file mode 100644 index 00000000..0850f7ad Binary files /dev/null and b/plugin.video.play.stuff/fanart.jpg differ diff --git a/plugin.video.play.stuff/icon.png b/plugin.video.play.stuff/icon.png new file mode 100644 index 00000000..244b9322 Binary files /dev/null and b/plugin.video.play.stuff/icon.png differ diff --git a/plugin.video.play.stuff/resources/__init__.py b/plugin.video.play.stuff/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.play.stuff/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.play.stuff/resources/language/resource.language.en_gb/strings.po b/plugin.video.play.stuff/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..062d1588 --- /dev/null +++ b/plugin.video.play.stuff/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,144 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "News" +msgstr "" + +msgctxt "#30002" +msgid "Sport" +msgstr "" + +msgctxt "#30003" +msgid "Entertainment" +msgstr "" + +msgctxt "#30004" +msgid "Life" +msgstr "" + +msgctxt "#30005" +msgid "Docos" +msgstr "" + +msgctxt "#30006" +msgid "Channels" +msgstr "" + +msgctxt "#30007" +msgid "Genres" +msgstr "" + +msgctxt "#30008" +msgid "Featured" +msgstr "" + +msgctxt "#30009" +msgid "Next Page ({next})" +msgstr "" + +msgctxt "#30010" +msgid "Search: {query} ({page}/{total_pages})" +msgstr "" + +msgctxt "#30011" +msgid "Series" +msgstr "" + +msgctxt "#30012" +msgid "Home" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32034" +msgid "General" +msgstr "" + +msgctxt "#32035" +msgid "Playback" +msgstr "" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "" + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "" + +msgctxt "#32023" +msgid "Use Inputstream HLS" +msgstr "" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "" + +msgctxt "#32036" +msgid "Advanced" +msgstr "" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "" + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" diff --git a/plugin.video.play.stuff/resources/lib/__init__.py b/plugin.video.play.stuff/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.play.stuff/resources/lib/api.py b/plugin.video.play.stuff/resources/lib/api.py new file mode 100644 index 00000000..656fca19 --- /dev/null +++ b/plugin.video.play.stuff/resources/lib/api.py @@ -0,0 +1,100 @@ +from slyguy import userdata, util +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.mem_cache import cached + +from .language import _ +from .constants import API_URL, HEADERS, UUID, APPID, LOCALE, BRIGHTCOVE_URL, BRIGHTCOVE_ACCOUNT, BRIGHTCOVE_KEY + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self._session = Session(HEADERS, base_url=API_URL) + + def search(self, query, page=1, pagesize=15): + params = { + 'uuid': UUID, + 'appId': APPID, + 'locale': LOCALE, + 'text': query, + 'pageSize': pagesize, + 'pageNumber': page, + 'sortBy': 'name', + 'sortOrder': 'asc', + } + + data = self._session.get('/search', params=params).json() + + for row in data.get('item', []): + row['attribs'] = {} + + for row2 in row['attributes']: + row['attribs'][row2['key']] = row2['value'] + + row.pop('attributes', None) + + return data + + @cached(60*30) + def page(self, id): + params = { + 'uuid': UUID, + 'appId': APPID, + 'locale': LOCALE, + } + + data = self._session.get('/page/{}'.format(id), params=params).json() + + items = {} + for row in data['item']: + row['attribs'] = {} + + for row2 in row['attributes']: + row['attribs'][row2['key']] = row2['value'] + + row.pop('attributes', None) + items[row['id']] = row + + containers = {} + for row in data['container']: + row['attribs'] = {} + row['items'] = [] + + for row2 in row['attributes']: + row['attribs'][row2['key']] = row2['value'] + + for item_id in row.get('itemId', []): + item = items[item_id] + row['items'].append(item) + + row.pop('itemId', None) + row.pop('attributes', None) + + containers[row['id']] = row + + page = data['page'] + page['attribs'] = {} + page['containers'] = [] + + for row2 in page['attributes']: + page['attribs'][row2['key']] = row2['value'] + + for container_id in page['containerId']: + container = containers[container_id] + if container['items']: + page['containers'].append(container) + + page.pop('containerId', None) + page.pop('attributes', None) + + return page + + def get_brightcove_src(self, referenceID): + brightcove_url = BRIGHTCOVE_URL.format(BRIGHTCOVE_ACCOUNT, referenceID) + + resp = self._session.get(brightcove_url, headers={'BCOV-POLICY': BRIGHTCOVE_KEY}) + data = resp.json() + + return util.process_brightcove(data) \ No newline at end of file diff --git a/plugin.video.play.stuff/resources/lib/constants.py b/plugin.video.play.stuff/resources/lib/constants.py new file mode 100644 index 00000000..cd16c0bd --- /dev/null +++ b/plugin.video.play.stuff/resources/lib/constants.py @@ -0,0 +1,15 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36', + 'Accept': 'application/json', + 'X-Forwarded-For' : '202.89.4.222', +} + +API_URL = 'https://play.stuff.co.nz/proxy{}' + +UUID = '5b04524e-3d88-41a0-bb06-f81c30ed5cc3' +APPID = '5cd25a3e1de1c4001c728977' +LOCALE = 'en' + +BRIGHTCOVE_URL = 'https://edge.api.brightcove.com/playback/v1/accounts/{}/videos/{}' +BRIGHTCOVE_KEY = 'BCpkADawqM3KfgdNYZ0Y7EWNjlastBDaiqfYCwjFesnSvtpP1SZJsutJNIVeME0BzpCPdI1U_Ux0mWRQ1_fu2Rv67E3yMgf6X4e3_9sdWPy72XjVP0mSTxjSI00cXResb0gKOZ33Ajp-1juF' +BRIGHTCOVE_ACCOUNT = '6005208634001' \ No newline at end of file diff --git a/plugin.video.play.stuff/resources/lib/language.py b/plugin.video.play.stuff/resources/lib/language.py new file mode 100644 index 00000000..42493d5e --- /dev/null +++ b/plugin.video.play.stuff/resources/lib/language.py @@ -0,0 +1,17 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + NEWS = 30001 + SPORT = 30002 + ENTERTAINMENT = 30003 + LIFE = 30004 + DOCOS = 30005 + CHANNELS = 30006 + GENRES = 30007 + FEATURED = 30008 + NEXT_PAGE = 30009 + SEARCH_FOR = 30010 + SERIES = 30011 + HOME = 30012 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.play.stuff/resources/lib/plugin.py b/plugin.video.play.stuff/resources/lib/plugin.py new file mode 100644 index 00000000..cf08dda3 --- /dev/null +++ b/plugin.video.play.stuff/resources/lib/plugin.py @@ -0,0 +1,140 @@ +import math + +from slyguy import plugin, gui, userdata, signals, inputstream, settings +from slyguy.constants import ADDON_PATH + +from .api import API +from .language import _ + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + folder.add_item(label=_(_.HOME, _bold=True), path=plugin.url_for(page, page_id='5e795ffe1de1c4001d3d2184')) + folder.add_item(label=_(_.NEWS, _bold=True), path=plugin.url_for(page, page_id='news')) + folder.add_item(label=_(_.SPORT, _bold=True), path=plugin.url_for(page, page_id='5d54ce0ba6f547001cde19bc')) + folder.add_item(label=_(_.ENTERTAINMENT, _bold=True), path=plugin.url_for(page, page_id='5d54ce4223eec6001dc2d819')) + folder.add_item(label=_(_.LIFE, _bold=True), path=plugin.url_for(page, page_id='5d54ce8023eec6001d24040b')) + folder.add_item(label=_(_.DOCOS, _bold=True), path=plugin.url_for(page, page_id='5d54cf8d23eec6001d240410')) + folder.add_item(label=_(_.CHANNELS, _bold=True), path=plugin.url_for(page, page_id='channels')) + folder.add_item(label=_(_.GENRES, _bold=True), path=plugin.url_for(page, page_id='genres')) + folder.add_item(label=_(_.SERIES, _bold=True), path=plugin.url_for(page, page_id='series')) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +def _process_items(rows): + items = [] + + for row in rows: + attribs = row['attribs'] + + if row['typeId'] == 'go-item-video': + item = plugin.Item( + label = attribs['title'], + info = { + 'plot': attribs['description'], + 'duration': int(attribs['video-duration']) / 1000, + }, + art = {'thumb': attribs['image-background-small']}, + path = plugin.url_for(play, id=attribs['assetId']), + playable = True, + ) + elif row['typeId'] == 'go-item-navigation': + item = plugin.Item( + label = attribs['title'], + info = { + 'plot': attribs.get('description'), + }, + art = {'thumb': attribs['image-background-small']}, + path = plugin.url_for(page, page_id=attribs['pageId']), + ) + + items.append(item) + + return items + +@plugin.route() +def search(query=None, page=1, **kwargs): + page = int(page) + + if not query: + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + data = api.search(query=query, page=page) + rows = data.get('item', []) + page_number = data['paginationMetadata']['pageNumber'] + page_size = data['paginationMetadata']['pageSize'] + total_items = data['paginationMetadata'].get('totalCount', 0) + total_pages = int(math.ceil(float(total_items) / page_size)) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query, page=page_number, total_pages=total_pages)) + + items = _process_items(rows) + folder.add_items(items) + + if total_pages > page_number: + folder.add_item( + label = _(_.NEXT_PAGE, next=page_number+1, total_pages=total_pages, _bold=True), + path = plugin.url_for(search, query=query, page=page_number+1), + ) + + return folder + +@plugin.route() +def page(page_id, container_id=None, **kwargs): + data = api.page(page_id) + + folder = plugin.Folder(data['title']) + + if container_id: + for container in data['containers']: + if container['id'] == container_id: + items = _process_items(container['items']) + folder.add_items(items) + + return folder + + if len(data['containers']) == 1: + container = data['containers'][0] + items = _process_items(container['items']) + folder.add_items(items) + return folder + + for container in data['containers']: + if container['templateId'] == 'go-container-hero': + folder.add_item( + label = _.FEATURED, + path = plugin.url_for(page, page_id=page_id, container_id=container['id']), + ) + elif 'buttonPage' not in container: + folder.add_item( + label = container['title'].title(), + path = plugin.url_for(page, page_id=page_id, container_id=container['id']), + ) + else: + folder.add_item( + label = container['title'].title(), + path = plugin.url_for(page, page_id=container['buttonPage']), + ) + + return folder + +@plugin.route() +def play(id, **kwargs): + return api.get_brightcove_src(id) \ No newline at end of file diff --git a/plugin.video.play.stuff/resources/settings.xml b/plugin.video.play.stuff/resources/settings.xml new file mode 100644 index 00000000..0f6490f3 --- /dev/null +++ b/plugin.video.play.stuff/resources/settings.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.rugbypass/__init__.py b/plugin.video.rugbypass/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.rugbypass/addon.xml b/plugin.video.rugbypass/addon.xml new file mode 100644 index 00000000..087b512e --- /dev/null +++ b/plugin.video.rugbypass/addon.xml @@ -0,0 +1,24 @@ + + + + + + + video + + + + Watch Rugby from RugbyPass.com + +Subscription required + true + + + + Add Bookmarks + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.rugbypass/default.py b/plugin.video.rugbypass/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.rugbypass/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.rugbypass/fanart.jpg b/plugin.video.rugbypass/fanart.jpg new file mode 100644 index 00000000..86a24d1d Binary files /dev/null and b/plugin.video.rugbypass/fanart.jpg differ diff --git a/plugin.video.rugbypass/icon.png b/plugin.video.rugbypass/icon.png new file mode 100644 index 00000000..d5c2f4de Binary files /dev/null and b/plugin.video.rugbypass/icon.png differ diff --git a/plugin.video.rugbypass/resources/__init__.py b/plugin.video.rugbypass/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.rugbypass/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.rugbypass/resources/language/resource.language.en_gb/strings.po b/plugin.video.rugbypass/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..205527c3 --- /dev/null +++ b/plugin.video.rugbypass/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,235 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Live" +msgstr "" + +msgctxt "#30002" +msgid "Played" +msgstr "" + +msgctxt "#30003" +msgid "Upcoming" +msgstr "" + +msgctxt "#30006" +msgid "RugbyPass Email" +msgstr "" + +msgctxt "#30007" +msgid "RugbyPass Password" +msgstr "" + +msgctxt "#30008" +msgid "Failed to login with below error:\n" +"{error_msg}" +msgstr "" + +msgctxt "#30010" +msgid "Reminder Set" +msgstr "" + +msgctxt "#30011" +msgid "Reminder Removed" +msgstr "" + +msgctxt "#30012" +msgid "Unable to get content url for this game" +msgstr "" + +msgctxt "#30013" +msgid "No Games" +msgstr "" + +msgctxt "#30014" +msgid "Could not find that game" +msgstr "" + +msgctxt "#30015" +msgid "Set Reminder" +msgstr "" + +msgctxt "#30016" +msgid "Remove Reminder" +msgstr "" + +msgctxt "#30017" +msgid "Watch Live" +msgstr "" + +msgctxt "#30018" +msgid "Watch from Start" +msgstr "" + +msgctxt "#30019" +msgid "Full Game" +msgstr "" + +msgctxt "#30020" +msgid "Condensed Game" +msgstr "" + +msgctxt "#30021" +msgid "Show Score" +msgstr "" + +msgctxt "#30022" +msgid "Live stream has started" +msgstr "" + +msgctxt "#30023" +msgid "Is about to kick-off" +msgstr "" + +msgctxt "#30024" +msgid "Watch" +msgstr "" + +msgctxt "#30025" +msgid "Close" +msgstr "" + +msgctxt "#30026" +msgid "Reminder Type" +msgstr "" + +msgctxt "#30027" +msgid "Pop-up Dialog" +msgstr "" + +msgctxt "#30028" +msgid "Notification" +msgstr "" + +msgctxt "#30029" +msgid "Reminder When" +msgstr "" + +msgctxt "#30030" +msgid "Stream Starts" +msgstr "" + +msgctxt "#30031" +msgid "Kick-off Time" +msgstr "" + +msgctxt "#30032" +msgid "Show scores" +msgstr "" + +msgctxt "#30033" +msgid "after ? hours" +msgstr "" + + +msgctxt "#30036" +msgid "A draw ({win_score} all)" +msgstr "" + +msgctxt "#30037" +msgid "{win_team} win {win_score} to {lose_score}" +msgstr "" + +msgctxt "#30038" +msgid "Your IP address isn't in an allowed area or is blocked\n" +"You may need to use a VPN or DNS service that isn't blocked" +msgstr "" + +msgctxt "#30039" +msgid "{home_team} host {away_team}\n" +"{kick_off}\n\n" +"{result}" +msgstr "" + +msgctxt "#30040" +msgid "Kick-off: {date_time}" +msgstr "" + +msgctxt "#30041" +msgid "{home_team} vs {away_team}" +msgstr "" + +msgctxt "#30042" +msgid "Your subscription does not have access to this content" +msgstr "" + +msgctxt "#30043" +msgid "Request was GEO blocked (blackout area)" +msgstr "" + +msgctxt "#30044" +msgid "Default Live Play Action" +msgstr "" + +msgctxt "#30045" +msgid "Ask" +msgstr "" + +msgctxt "#30046" +msgid "From Live" +msgstr "" + +msgctxt "#30047" +msgid "From Start" +msgstr "" + +msgctxt "#30048" +msgid "Play From?" +msgstr "" + +msgctxt "#30049" +msgid "Inputstream HLS required to watch streams From Live.\n" +"Check HLS is enabled in settings and Inputstream Adaptive is installed" +msgstr "" + +msgctxt "#30050" +msgid "Failed to get games list.\n" +"Check your subscription" +msgstr "" + + + + + +msgctxt "#30059" +msgid "Next Page ({page})" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" diff --git a/plugin.video.rugbypass/resources/lib/__init__.py b/plugin.video.rugbypass/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.rugbypass/resources/lib/api.py b/plugin.video.rugbypass/resources/lib/api.py new file mode 100644 index 00000000..ce9b738c --- /dev/null +++ b/plugin.video.rugbypass/resources/lib/api.py @@ -0,0 +1,113 @@ +import arrow + +from slyguy import settings, userdata +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.language import _ + +from .constants import HEADERS, API_URL +from .models import Game +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self._session = Session(HEADERS, base_url=API_URL) + self.logged_in = userdata.get('token') != None + + def login(self, username, password): + data = { + 'username': username, + 'password': password, + 'cookielink': 'true', + 'format': 'json', + } + + r = self._session.post('/secure/authenticate', data=data) + data = r.json() + token = r.cookies.get('nllinktoken') + + if not token: + raise APIError(data.get('code')) + + userdata.set('token', token) + + def get_play_url(self, game, game_type): + params = { + 'id': game.id, + 'gs': game.state, + 'gt': game_type, + 'type': 'game', + 'format': 'json', + } + + if game.state == Game.PROCESSING: + params['st'] = game.start * 1000 + params['dur'] = game.duration * 1000 + + cookies = {'nllinktoken': userdata.get('token'), 'RugbyLoggedIn': userdata.get('username')} + + resp = self._session.get('/service/publishpoint', params=params, cookies=cookies) + if not resp.ok: + data = self._session.get('/game/{}'.format(game.slug), params={'format':'json', 'purchases': True}, cookies=cookies).json() + if data.get('noAccess'): + raise APIError(_.NO_ACCESS) + elif 'blackout' in data: + raise APIError(_.GEO_ERROR, heading=_.GEO_HEADING) + else: + raise APIError(_.PLAY_ERROR) + + return resp.json()['path'] + + def _parse_game(self, item): + def get_timestamp(key): + if key in item: + return arrow.get(item[key]).timestamp + else: + return 0 + + info = {'home': item['homeTeam'], 'away': item['awayTeam']} + game = Game(id=int(item['id']), state=int(item['gameState']), start=get_timestamp('dateTimeGMT'), + end=get_timestamp('endDateTimeGMT'), slug=str(item['seoName']), info=info) + + return game + + def update_games(self): + to_create = [] + + data = self._session.get('/scoreboard', params={'format':'json'}).json() + if 'games' not in data: + if data.get('code') == 'failedgeo': + raise APIError(_.GEO_ERROR, heading=_.GEO_HEADING) + else: + raise APIError(_.GAMES_ERROR) + + for row in data['games']: + game = self._parse_game(row) + to_create.append(game) + + Game.truncate() + Game.bulk_create(to_create, batch_size=100) + + def fetch_game(self, slug): + data = self._session.get('/game/{}'.format(slug), params={'format':'json'}).json() + return self._parse_game(data) + + def channels(self): + return self._session.get('/channels', params={'format':'json'}).json() + + def search(self, cat_id, query, page=1): + params = { + 'param': '*{}*'.format(query), + 'fq': 'catId2:{}'.format(cat_id), + 'pn': page, + 'format':'json', + } + + return self._session.get('/search', params=params).json() + + def logout(self): + userdata.delete('token') + self.new_session() \ No newline at end of file diff --git a/plugin.video.rugbypass/resources/lib/constants.py b/plugin.video.rugbypass/resources/lib/constants.py new file mode 100644 index 00000000..df972b1a --- /dev/null +++ b/plugin.video.rugbypass/resources/lib/constants.py @@ -0,0 +1,10 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36', +} + +API_URL = 'https://watch.rugbypass.com{}' +IMG_URL = 'https://neulionsmbnyc-a.akamaihd.net/u/mt1/csmrugby/thumbs/{}' + +GAMES_EXPIRY = (60*5) #5 minutes +GAMES_CACHE_KEY = 'games_updated' +SERVICE_TIME = (GAMES_EXPIRY - 30) \ No newline at end of file diff --git a/plugin.video.rugbypass/resources/lib/language.py b/plugin.video.rugbypass/resources/lib/language.py new file mode 100644 index 00000000..aa8e82de --- /dev/null +++ b/plugin.video.rugbypass/resources/lib/language.py @@ -0,0 +1,52 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + LIVE = 30001 + PLAYED = 30002 + UPCOMING = 30003 + ASK_USERNAME = 30006 + ASK_PASSWORD = 30007 + LOGIN_ERROR = 30008 + REMINDER_SET = 30010 + REMINDER_REMOVED = 30011 + PLAY_ERROR = 30012 + NO_GAMES = 30013 + ERROR_GAME_NOT_FOUND = 30014 + SET_REMINDER = 30015 + REMOVE_REMINDER = 30016 + WATCH_LIVE = 30017 + WATCH_FROM_START = 30018 + FULL_GAME = 30019 + CONDENSED_GAME = 30020 + SHOW_SCORE = 30021 + STREAM_STARTED = 30022 + KICKOFF = 30023 + WATCH = 30024 + CLOSE = 30025 + REMINDER_TYPE = 30026 + POP_UP_DIALOG = 30027 + NOTIFCATION = 30028 + REMINDER_WHEN = 30029 + STREAM_STARTS = 30030 + KICK_OFF_TIME = 30031 + SHOW_SCORES = 30032 + AFTER_X_HOURS = 30033 + + + A_DRAW = 30036 + X_WINS = 30037 + GEO_ERROR = 30038 + GAME_DESC = 30039 + KICK_OFF = 30040 + GAME_TITLE = 30041 + NO_ACCESS = 30042 + GEO_HEADING = 30043 + LIVE_PLAY_TYPE = 30044 + + PLAY_FROM = 30048 + HLS_REQUIRED = 30049 + GAMES_ERROR = 30050 + + NEXT_PAGE = 30059 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.rugbypass/resources/lib/models.py b/plugin.video.rugbypass/resources/lib/models.py new file mode 100644 index 00000000..a9844cf7 --- /dev/null +++ b/plugin.video.rugbypass/resources/lib/models.py @@ -0,0 +1,84 @@ +import arrow +import peewee + +from slyguy import database, settings + +from .constants import IMG_URL +from .language import _ + +class Game(database.Model): + FULL = 1 + CONDENSED = 8 + + UPCOMING = 0 #Not yet played + LIVE = 1 #Live + PROCESSING = 2 #Can re-watch entire live stream + PLAYED = 3 #Can watch full and condensend game + + id = peewee.IntegerField(primary_key=True) + slug = peewee.TextField(unique=True, index=True) + state = peewee.IntegerField(index=True) + start = peewee.IntegerField() + end = peewee.IntegerField() + info = database.JSONField() + + @property + def result(self): + home = self.info['home'] + away = self.info['away'] + + if home['score'] == '' or away['score'] == '': + return None + if int(home['score']) == int(away['score']): + return _(_.A_DRAW, win_team=home['name'], win_score=home['score'], lose_team=away['name'], lose_score=away['score']) + elif int(home['score']) > int(away['score']): + return _(_.X_WINS, win_team=home['name'], win_score=home['score'], lose_team=away['name'], lose_score=away['score']) + else: + return _(_.X_WINS, win_team=away['name'], win_score=away['score'], lose_team=home['name'], lose_score=home['score']) + + @property + def aired(self): + return arrow.get(self.start).to('local').isoformat() + + @property + def description(self): + home = self.info['home'] + away = self.info['away'] + show_hours = settings.getInt('show_hours') if settings.getBool('show_score') else -1 + + result = '' + if home['score'] and away['score'] and show_hours != -1 and arrow.now() > arrow.get(self.start).shift(hours=show_hours): + result = self.result + + return _(_.GAME_DESC, home_team=home['name'], away_team=away['name'], kick_off=self.kickoff, result=result) + + @property + def kickoff(self): + return _(_.KICK_OFF, date_time=arrow.get(self.start).to('local').format('h:mmA D/M/YY')) + + @property + def duration(self): + if self.end == 0: + return None + return self.end - self.start + + @property + def playable(self): + return self.state in (Game.LIVE, Game.PROCESSING, Game.PLAYED) + + @property + def title(self): + return _(_.GAME_TITLE, home_team=self.info['home']['name'], away_team=self.info['away']['name']) + + @property + def image(self): + return IMG_URL.format('/teams/{}_ph_eb.png'.format(self.info['home']['code'])) + +class Alert(object): + DIALOG = 0 + NOTIFICATION = 1 + + STREAM_START = 0 + KICK_OFF = 1 + +database.tables.append(Game) \ No newline at end of file diff --git a/plugin.video.rugbypass/resources/lib/plugin.py b/plugin.video.rugbypass/resources/lib/plugin.py new file mode 100644 index 00000000..d32760a8 --- /dev/null +++ b/plugin.video.rugbypass/resources/lib/plugin.py @@ -0,0 +1,231 @@ +import arrow + +from slyguy import settings, plugin, database, cache, gui, userdata, inputstream, util, signals +from slyguy.exceptions import PluginError +from slyguy.constants import PLAY_FROM_TYPES, PLAY_FROM_ASK, PLAY_FROM_LIVE, PLAY_FROM_START + +from .constants import SERVICE_TIME, GAMES_EXPIRY, GAMES_CACHE_KEY, IMG_URL +from .api import API +from .models import Game, Alert +from .language import _ + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE, _bold=True), path=plugin.url_for(live), cache_key=GAMES_CACHE_KEY) + folder.add_item(label=_(_.PLAYED, _bold=True), path=plugin.url_for(played), cache_key=GAMES_CACHE_KEY) + folder.add_item(label=_(_.UPCOMING, _bold=True), path=plugin.url_for(upcoming), cache_key=GAMES_CACHE_KEY) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +def live(**kwargs): + return show_games(Game.state == Game.LIVE, title=_.LIVE) + +@plugin.route() +def played(**kwargs): + return show_games(Game.state << (Game.PROCESSING, Game.PLAYED), title=_.PLAYED) + +@plugin.route() +def upcoming(**kwargs): + return show_games(Game.state == Game.UPCOMING, order_by=Game.start.asc(), title=_.UPCOMING) + +@plugin.route() +def alerts(slug, **kwargs): + game = get_game(slug) + alerts = userdata.get('alerts', []) + + if game.id not in alerts: + alerts.append(game.id) + gui.notification(_.REMINDER_SET, heading=game.title, icon=game.image) + else: + alerts.remove(game.id) + gui.notification(_.REMINDER_REMOVED, heading=game.title, icon=game.image) + + userdata.set('alerts', alerts) + gui.refresh() + +@plugin.route() +def show_score(slug, **kwargs): + game = get_game(slug) + gui.ok(heading=game.title, message=game.result) + +@plugin.route() +def play(slug, game_type, play_type=PLAY_FROM_LIVE, **kwargs): + game = get_game(slug) + return _get_play_item(game, game_type, play_type) + +@plugin.login_required() +def _get_play_item(game, game_type, play_type=PLAY_FROM_LIVE): + play_type = int(play_type) + item = parse_game(game) + is_live = game.state == Game.LIVE + + item.inputstream = inputstream.HLS(live=is_live) + + if play_type == PLAY_FROM_START or (play_type == PLAY_FROM_ASK and not gui.yes_no(_.PLAY_FROM, yeslabel=_.PLAY_FROM_LIVE, nolabel=_.PLAY_FROM_START)): + item.properties['ResumeTime'] = '1' + item.properties['TotalTime'] = '1' + if is_live and not item.inputstream.check(): + raise PluginError(_.HLS_REQUIRED) + + item.path = api.get_play_url(game, game_type) + + return item + +@signals.on(signals.ON_SERVICE) +def service(): + update_games() + check_alerts() + +def show_games(query, order_by=None, title=None): + folder = plugin.Folder(title, no_items_label=_.NO_GAMES) + + if not order_by: + order_by = Game.start.desc() + + if not cache.get(GAMES_CACHE_KEY): + update_games() + + games = Game.select().where(query).order_by(order_by) + items = [parse_game(game) for game in games] + folder.add_items(items) + + return folder + +def update_games(): + api.update_games() + cache.set(GAMES_CACHE_KEY, True, expires=GAMES_EXPIRY) + +def get_game(slug): + game = Game.get_or_none(Game.slug == slug) + if not game: + try: + game = api.fetch_game(slug) + game.save() + except: + raise PluginError(_.ERROR_GAME_NOT_FOUND) + + return game + +def parse_game(game): + item = plugin.Item( + label = game.title, + is_folder = False, + playable = game.state != Game.UPCOMING, + art = {'thumb': game.image}, + info = { + 'title': game.title, + 'plot': game.description, + 'duration': game.duration, + 'aired': game.aired, + }, + ) + + if game.state == Game.UPCOMING: + item.path = plugin.url_for(alerts, slug=game.slug) + + if game.id not in userdata.get('alerts', []): + item.info['playcount'] = 0 + item.context.append((_.SET_REMINDER, "RunPlugin({0})".format(item.path))) + else: + item.info['playcount'] = 1 + item.context.append((_.REMOVE_REMINDER, "RunPlugin({0})".format(item.path))) + + elif game.state == Game.LIVE: + item.path = plugin.url_for(play, slug=game.slug, game_type=Game.FULL, play_type=settings.getEnum('live_play_type', PLAY_FROM_TYPES, default=PLAY_FROM_ASK), _is_live=True) + + item.context.append((_.WATCH_LIVE, "PlayMedia({0})".format( + plugin.url_for(play, slug=game.slug, game_type=Game.FULL, play_type=PLAY_FROM_LIVE, _is_live=True) + ))) + + item.context.append((_.WATCH_FROM_START, "PlayMedia({0})".format( + plugin.url_for(play, slug=game.slug, game_type=Game.FULL, play_type=PLAY_FROM_START, _is_live=True) + ))) + + elif game.state == Game.PROCESSING: + item.path = plugin.url_for(play, slug=game.slug, game_type=Game.FULL) + item.context.append((_.FULL_GAME, "PlayMedia({0})".format(item.path))) + + elif game.state == Game.PLAYED: + item.path = plugin.url_for(play, slug=game.slug, game_type=Game.FULL) + item.context.append((_.FULL_GAME, "PlayMedia({0})".format(item.path))) + item.context.append((_.CONDENSED_GAME, "PlayMedia({0})".format( + plugin.url_for(play, slug=game.slug, game_type=Game.CONDENSED) + ))) + + if game.result: + item.context.append((_.SHOW_SCORE, "RunPlugin({0})".format( + plugin.url_for(show_score, slug=game.slug) + ))) + + return item + +def check_alerts(): + alerts = userdata.get('alerts', []) + if not alerts: return + + for game in Game.select().where(Game.id << alerts): + if game.state == Game.LIVE: + alerts.remove(game.id) + + _to_start = game.start - arrow.utcnow().timestamp + + if settings.getInt('alert_when') == Alert.STREAM_START: + message = _.STREAM_STARTED + elif settings.getInt('alert_when') == Alert.KICK_OFF and _to_start > 0 and _to_start <= SERVICE_TIME: + message = _.KICKOFF + else: + continue + + if settings.getInt('alert_type') == Alert.NOTIFICATION: + gui.notification(message, heading=game.title, time=5000, icon=game.image) + + elif gui.yes_no(message, heading=game.title, yeslabel=_.WATCH, nolabel=_.CLOSE): + _get_play_item(game, Game.FULL, play_type=settings.getEnum('live_play_type', PLAY_FROM_TYPES, default=PLAY_FROM_ASK)).play() + + elif game.state != Game.UPCOMING: + alerts.remove(game.id) + + userdata.set('alerts', alerts) \ No newline at end of file diff --git a/plugin.video.rugbypass/resources/settings.xml b/plugin.video.rugbypass/resources/settings.xml new file mode 100644 index 00000000..b490aa0c --- /dev/null +++ b/plugin.video.rugbypass/resources/settings.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.rugbypass/service.py b/plugin.video.rugbypass/service.py new file mode 100644 index 00000000..ff1a5c02 --- /dev/null +++ b/plugin.video.rugbypass/service.py @@ -0,0 +1,3 @@ +from slyguy.service import run + +run() \ No newline at end of file diff --git a/plugin.video.showmax/__init__.py b/plugin.video.showmax/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.showmax/addon.xml b/plugin.video.showmax/addon.xml new file mode 100644 index 00000000..08c6b79a --- /dev/null +++ b/plugin.video.showmax/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Watch your favourite content from Showmax. + +Subscription required. + true + + + + Add codec settings. Fix multi-language content + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.showmax/default.py b/plugin.video.showmax/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.showmax/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.showmax/fanart.jpg b/plugin.video.showmax/fanart.jpg new file mode 100644 index 00000000..16fc2334 Binary files /dev/null and b/plugin.video.showmax/fanart.jpg differ diff --git a/plugin.video.showmax/icon.png b/plugin.video.showmax/icon.png new file mode 100644 index 00000000..e08b0c2c Binary files /dev/null and b/plugin.video.showmax/icon.png differ diff --git a/plugin.video.showmax/resources/__init__.py b/plugin.video.showmax/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.showmax/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.showmax/resources/language/resource.language.en_gb/strings.po b/plugin.video.showmax/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..80f80524 --- /dev/null +++ b/plugin.video.showmax/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,88 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Showmax Email" +msgstr "" + +msgctxt "#30001" +msgid "Showmax Password" +msgstr "" + +msgctxt "#30002" +msgid "Series" +msgstr "" + +msgctxt "#30003" +msgid "Movies" +msgstr "" + +msgctxt "#30004" +msgid "Kids" +msgstr "" + +msgctxt "#30005" +msgid "Season {season_number}" +msgstr "" + +msgctxt "#30006" +msgid "Episode {episode_number}" +msgstr "" + +msgctxt "#30007" +msgid "H.264" +msgstr "" + +msgctxt "#30008" +msgid "English" +msgstr "" + +msgctxt "#30009" +msgid "Polish" +msgstr "" + +msgctxt "#30010" +msgid "Afrikaans" +msgstr "" + +msgctxt "#30011" +msgid "H.265" +msgstr "" + +msgctxt "#30012" +msgid "Preferred Audio Language" +msgstr "" + +msgctxt "#30013" +msgid "VP9" +msgstr "" + +msgctxt "#30014" +msgid "Select Language" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" diff --git a/plugin.video.showmax/resources/lib/__init__.py b/plugin.video.showmax/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.showmax/resources/lib/api.py b/plugin.video.showmax/resources/lib/api.py new file mode 100644 index 00000000..166b2c06 --- /dev/null +++ b/plugin.video.showmax/resources/lib/api.py @@ -0,0 +1,217 @@ +import hashlib + +from bs4 import BeautifulSoup +from slyguy import userdata, settings +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.log import log + +from .constants import * + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self._logged_in = False + self._language = COMM_LANG + self._session = Session(HEADERS, base_url=API_URL) + self._set_access_token(userdata.get('access_token')) + + def _set_access_token(self, token): + if token: + self._session.headers.update({'Authorization': 'Bearer {}'.format(token)}) + self._logged_in = True + + @property + def logged_in(self): + return self._logged_in + + def login(self, username, password): + self.logout() + + data = { + 'response_type': 'token', + 'lang': self._language, + } + + resp = self._session.get(LOGIN_URL, params=data) + + soup = BeautifulSoup(resp.text, 'html.parser') + for form in soup.find_all('form'): + data = {} + + for e in form.find_all('input'): + data[e.attrs['name']] = e.attrs.get('value') + + if 'signin[email]' in data: + break + + data.update({ + 'signin[email]': username, + 'signin[password]': password, + }) + + resp = self._session.post(LOGIN_URL, data=data, allow_redirects=False) + access_token = resp.cookies.get('showmax_oauth') + + if not access_token: + raise APIError('Failed to login') + + self._set_access_token(access_token) + + data = self._session.get('user/current', params={'lang': self._language}).json() + if 'error_code' in data: + raise APIError('Failed to login') + + device_id = hashlib.sha1(username.lower().strip().encode('utf8')).hexdigest().upper() + + userdata.set('device_id', device_id) + userdata.set('access_token', access_token) + userdata.set('user_id', data['user_id']) + + def logout(self): + userdata.delete('device_id') + userdata.delete('access_token') + userdata.delete('user_id') + self.new_session() + + def _catalogue(self, _params): + def process_page(start): + params = { + 'field[]': ['id'], + 'lang': self._language, + 'showmax_rating': '18-plus', + 'sort': 'alphabet', + 'start': start, + 'subscription_status': 'full', + } + + params.update(_params) + + data = self._session.get('catalogue/assets', params=params).json() + items = data['items'] + + count = int(data.get('count', 0)) + remaining = int(data.get('remaining', 0)) + if count > 0 and remaining > 0: + items.extend(process_page(start + count)) + + return items + + return process_page(start=0) + + def series(self): + return self._catalogue({ + 'field[]': ['id', 'images', 'title', 'items', 'total', 'description', 'videos', 'type'], + 'type': 'tv_series', + }) + + def movies(self): + return self._catalogue({ + 'field[]': ['id', 'images', 'title', 'items', 'total', 'description', 'videos', 'type'], + 'type': 'movie', + }) + + def kids(self): + return self._catalogue({ + 'field[]': ['id', 'images', 'title', 'items', 'total', 'description', 'videos', 'type'], + 'showmax_rating': '5-6', + 'types[]': ['tv_series', 'movie'], + }) + + def seasons(self, series_id): + params = { + 'field[]': ['id', 'images', 'title', 'items', 'total', 'description', 'number', 'seasons', 'type'], + 'lang': self._language, + 'showmax_rating': '18-plus', + 'subscription_status': 'full', + } + + return self._session.get('catalogue/tv_series/{}'.format(series_id), params=params).json() + + def episodes(self, season_id): + params = { + 'field[]': ['id', 'images', 'title', 'items', 'total', 'description', 'number', 'tv_series', 'episodes', 'videos', 'type'], + 'lang': self._language, + 'showmax_rating': '18-plus', + 'subscription_status': 'full', + } + + return self._session.get('catalogue/season/{}'.format(season_id), params=params).json() + + def search(self, query): + return self._catalogue({ + 'field[]': ['id', 'images', 'title', 'items', 'total', 'type', 'description', 'type', 'videos'], + 'types[]': ['tv_series', 'movie'], + 'showmax_rating': '18-plus', + 'subscription_status': 'full', + 'q': query, + }) + + def asset(self, asset_id): + params = { + 'field[]': ['videos',], + 'exclude[]': 'episodes', + 'lang': self._language, + 'showmax_rating': '18-plus', + 'subscription_status': 'full', + } + + return self._session.get('catalogue/asset/{}'.format(asset_id), params=params).json()['videos'] + + def play(self, video_id): + codecs = '' + + if settings.getBool('vp9', False): + codecs += 'vp9+' + if settings.getBool('h265', False): + codecs += 'hevc+' + if settings.getBool('h264', True): + codecs += 'h264+' + + codecs = codecs.rstrip('+') + + params = { + 'capabilities[]': ['codecs={}'.format(codecs), 'multiaudio'], + 'encoding': 'mpd_widevine_modular', + 'subscription_status': 'full', + 'mode': 'paid', + 'showmax_rating': '18-plus', + 'lang': self._language, + # 'content_country': 'ZA', + } + + ## Temp below use old api until Proxy quality player fixed (showmax blocks too quick calls) + data = self._session.get('playback/play/{}'.format(video_id), params=params).json() + if 'url' not in data: + raise APIError(data.get('message')) + + url = data['url'] + task_id = data['packaging_task_id'] + session_id = data['session_id'] + + data = { + 'user_id': userdata.get('user_id'), + 'video_id': video_id, + 'hw_code': userdata.get('device_id'), + 'packaging_task_id': task_id, + 'session_id': session_id, + } + + params = { + 'showmax_rating': '18-plus', + 'mode': 'paid', + 'subscription_status': 'full', + 'lang': self._language, + } + + data = self._session.post('playback/verify', params=params, data=data).json() + + if 'license_request' not in data: + raise APIError(data.get('message')) + + license_request = data['license_request'] + license_url = API_URL.format('drm/widevine_modular?license_request={}'.format(license_request)) + + return url, license_url \ No newline at end of file diff --git a/plugin.video.showmax/resources/lib/constants.py b/plugin.video.showmax/resources/lib/constants.py new file mode 100644 index 00000000..a8e694a1 --- /dev/null +++ b/plugin.video.showmax/resources/lib/constants.py @@ -0,0 +1,13 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (PlayStation 0 5.55) AppleWebKit/537.73 (KHTML, like Gecko)', + 'X-Requested-With': 'com.showmax.app', +} + +API_URL = 'https://api.showmax.com/v134.0/android/{}' +LOGIN_URL = 'https://secure.showmax.com/v134.0/android/signin' + +THUMB_HEIGHT = 500 +FANART_HEIGHT = 720 + +COMM_LANG = 'eng' +AUDIO_LANGS = [None, 'eng', 'pol', 'afr'] \ No newline at end of file diff --git a/plugin.video.showmax/resources/lib/language.py b/plugin.video.showmax/resources/lib/language.py new file mode 100644 index 00000000..0e4f1285 --- /dev/null +++ b/plugin.video.showmax/resources/lib/language.py @@ -0,0 +1,20 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30000 + ASK_PASSWORD = 30001 + SERIES = 30002 + MOVIES = 30003 + KIDS = 30004 + SEASON_NUMBER = 30005 + EPISODE_NUMBER = 30006 + H264 = 30007 + LANG_ENG = 30008 + LANG_POL = 30009 + LANG_AFR = 30010 + H265 = 30011 + AUDIO_LANGUAGE = 30012 + VP9 = 30013 + SELECT_LANG = 30014 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.showmax/resources/lib/plugin.py b/plugin.video.showmax/resources/lib/plugin.py new file mode 100644 index 00000000..a53bd0d2 --- /dev/null +++ b/plugin.video.showmax/resources/lib/plugin.py @@ -0,0 +1,301 @@ +from kodi_six import xbmcplugin + +from slyguy import plugin, gui, settings, userdata, inputstream, signals +from slyguy.log import log + +from .api import API +from .constants import * +from .language import _ + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + if not plugin.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.SERIES, _bold=True), path=plugin.url_for(series)) + folder.add_item(label=_(_.MOVIES, _bold=True), path=plugin.url_for(movies)) + folder.add_item(label=_(_.KIDS, _bold=True), path=plugin.url_for(kids)) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def series(**kwargs): + folder = plugin.Folder(_.SERIES) + rows = api.series() + folder.add_items(_parse_rows(rows)) + return folder + +@plugin.route() +def movies(**kwargs): + folder = plugin.Folder(_.MOVIES) + rows = api.movies() + folder.add_items(_parse_rows(rows)) + return folder + +@plugin.route() +def kids(**kwargs): + folder = plugin.Folder(_.KIDS) + rows = api.kids() + folder.add_items(_parse_rows(rows)) + return folder + +@plugin.route() +def search(**kwargs): + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + rows = api.search(query) + folder.add_items(_parse_rows(rows)) + + return folder + +@plugin.route() +def seasons(series_id, **kwargs): + data = api.seasons(series_id) + art = _get_art(data['images']) + rows = data['seasons'] + + folder = plugin.Folder(data['title']) + folder.add_items(_parse_seasons(rows, data['title'], art)) + + return folder + +@plugin.route() +def episodes(season_id, **kwargs): + data = api.episodes(season_id) + art = _get_art(data['images']) + rows = data['episodes'] + + folder = plugin.Folder(data['tv_series']['title'], sort_methods=[xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_UNSORTED, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED]) + folder.add_items(_parse_episodes(rows, data['tv_series']['title'], data['number'], art)) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +@plugin.login_required() +def play_trailer(asset_id, **kwargs): + row = api.asset(asset_id) + videos = _get_videos(row) + return _play_videos(videos['trailer']) + +@plugin.route() +@plugin.login_required() +def play(asset_id, **kwargs): + row = api.asset(asset_id) + videos = _get_videos(row) + return _play_videos(videos['main']) + +def _play_videos(videos): + if not videos: + plugin.exception('No videos found') + + default_audio = settings.getEnum('audio_lang', AUDIO_LANGS) + + if len(videos) == 1: + chosen = videos[0] + else: + videos = sorted(videos, key=lambda x: x['language']) + + chosen = None + for video in videos: + if video['language']['iso_639_3'].lower() == default_audio: + chosen = video + break + + if not chosen: + index = gui.select(_.SELECT_LANG, [x['language']['name'] for x in videos]) + if index < 0: + return + + chosen = videos[index] + + url, license_url = api.play(chosen['id']) + item = plugin.Item( + inputstream = inputstream.Widevine(license_url), + path = url, + headers = HEADERS, + ) + + return item + +def _parse_series(rows): + items = [] + + for row in rows: + videos = _get_videos(row.get('videos', [])) + + item = plugin.Item( + label = row['title'], + info = {'sorttitle': row['title'], 'plot': row['description'], 'tvshowtitle': row['title']}, + art = _get_art(row.get('images', [])), + path = plugin.url_for(seasons, series_id=row['id']), + ) + + if videos['trailer']: + item.info['trailer'] = plugin.url_for(play_trailer, asset_id=row['id']) + + items.append(item) + + return items + +def _parse_seasons(rows, series_title, series_art): + items = [] + + for row in rows: + item = plugin.Item( + label = _(_.SEASON_NUMBER, season_number=row['number']), + info = {'plot': row['description'], 'tvshowtitle': series_title, 'season': row['number']}, + art = _get_art(row.get('images', []), series_art), + path = plugin.url_for(episodes, season_id=row['id']), + ) + + items.append(item) + + return items + +def _parse_episodes(rows, series_title, season, season_art): + items = [] + + for row in rows: + videos = _get_videos(row.get('videos', [])) + + item = plugin.Item( + label = row['title'] or _(_.EPISODE_NUMBER, episode_number=row['number']), + info = {'plot': row['description'], 'tvshowtitle': series_title, 'season': season, 'episode': row['number'], 'mediatype': 'episode'}, + art = _get_art(row.get('images', []), season_art), + is_folder = False, + ) + + if videos['main']: + item.info.update({ + 'duration': int(videos['main'][0]['duration']), + 'mediatype': 'episode', + }) + + item.video = {'height': videos['main'][0]['height'], 'width': videos['main'][0]['width'], 'codec': 'h264'} + item.path = plugin.url_for(play, asset_id=row['id']) + item.playable = True + + items.append(item) + + return items + +def _parse_movies(rows): + items = [] + + for row in rows: + videos = _get_videos(row.get('videos', [])) + + item = plugin.Item( + label = row['title'], + info = {'plot': row['description']}, + art = _get_art(row.get('images', [])), + is_folder = False, + ) + + if videos['main']: + item.info.update({ + 'duration': int(videos['main'][0]['duration']), + 'mediatype': 'movie', + }) + + item.video = {'height': videos['main'][0]['height'], 'width': videos['main'][0]['width'], 'codec': 'h264'} + item.path = plugin.url_for(play, asset_id=row['id']) + item.playable = True + + if videos['trailer']: + item.info['trailer'] = plugin.url_for(play_trailer, asset_id=row['id']) + + items.append(item) + + return items + +def _parse_rows(rows): + items = [] + + for row in rows: + if row['type'] == 'movie': + items.extend(_parse_movies([row])) + elif row['type'] == 'tv_series': + items.extend(_parse_series([row])) + + return items + +def _get_videos(videos): + vids = {'main': [], 'trailer': []} + + for video in videos: + if video['usage'] == 'main': + vids['main'].append(video) + elif video['usage'] == 'trailer': + vids['trailer'].append(video) + + return vids + +def _get_art(images, default_art=None, fanart=True): + art = {} + default_art = default_art or {} + + for image in images: + if image['type'] == 'poster': + if image['orientation'] == 'square' or 'thumb' not in art: + art['thumb'] = image['link'] + '/x{}'.format(THUMB_HEIGHT) + elif image['type'] == 'background': + art['fanart'] = image['link'] + '/x{}'.format(FANART_HEIGHT) + elif image['type'] == 'hero' and 'fanart' not in art: + art['fanart'] = image['link'] + '/x{}'.format(FANART_HEIGHT) + elif image['type'] == 'poster' and image['orientation'] == 'portrait': + art['poster'] = image['link'] + '/x{}'.format(THUMB_HEIGHT) + + for key in default_art: + if key not in art: + art[key] = default_art[key] + + if fanart == False: + art.pop('fanart', None) + + return art \ No newline at end of file diff --git a/plugin.video.showmax/resources/settings.xml b/plugin.video.showmax/resources/settings.xml new file mode 100644 index 00000000..c6e63946 --- /dev/null +++ b/plugin.video.showmax/resources/settings.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.sky.sport.now/.iptv_merge b/plugin.video.sky.sport.now/.iptv_merge new file mode 100644 index 00000000..b548a53b --- /dev/null +++ b/plugin.video.sky.sport.now/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/SkySportNow/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.sky.sport.now/__init__.py b/plugin.video.sky.sport.now/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.sky.sport.now/addon.xml b/plugin.video.sky.sport.now/addon.xml new file mode 100644 index 00000000..dd4ad8d7 --- /dev/null +++ b/plugin.video.sky.sport.now/addon.xml @@ -0,0 +1,24 @@ + + + + + + + video + + + Full Streaming access to all 12 SKY Sport and ESPN channels. +Sky Sport Now will give you all the LIVE Sports action you love online and on the go, as well as a huge range of feature content, highlights, stats and more! + +Subscription required. + true + + + + Add Bookmarks + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.sky.sport.now/default.py b/plugin.video.sky.sport.now/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.sky.sport.now/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.sky.sport.now/fanart.jpg b/plugin.video.sky.sport.now/fanart.jpg new file mode 100644 index 00000000..86a24d1d Binary files /dev/null and b/plugin.video.sky.sport.now/fanart.jpg differ diff --git a/plugin.video.sky.sport.now/icon.png b/plugin.video.sky.sport.now/icon.png new file mode 100644 index 00000000..b7eb973a Binary files /dev/null and b/plugin.video.sky.sport.now/icon.png differ diff --git a/plugin.video.sky.sport.now/resources/__init__.py b/plugin.video.sky.sport.now/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.sky.sport.now/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.sky.sport.now/resources/language/resource.language.en_gb/strings.po b/plugin.video.sky.sport.now/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..52bfc06d --- /dev/null +++ b/plugin.video.sky.sport.now/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,83 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Live TV" +msgstr "" + +msgctxt "#30004" +msgid "Highlights" +msgstr "" + +msgctxt "#30005" +msgid " ({cur_page}/{total_pages})" +msgstr "" + +msgctxt "#30006" +msgid "Next Page" +msgstr "" + +msgctxt "#30007" +msgid "Sky Sport Now is only available to New Zealand IP Addresses" +msgstr "" + +msgctxt "#30008" +msgid "Failed to get playback data\n" +"Server error code: {code}" +msgstr "" + +msgctxt "#30009" +msgid "Unknown Error\n" +"Check you have a valid subscription" +msgstr "" + +msgctxt "#30010" +msgid "Failed to login\n" +"Server error code: {code}" +msgstr "" + +msgctxt "#30011" +msgid "Replay" +msgstr "" + +msgctxt "#30012" +msgid "This event has not started yet" +msgstr "" + +msgctxt "#30013" +msgid "You can only view events within last 24 hours" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" diff --git a/plugin.video.sky.sport.now/resources/lib/__init__.py b/plugin.video.sky.sport.now/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.sky.sport.now/resources/lib/api.py b/plugin.video.sky.sport.now/resources/lib/api.py new file mode 100644 index 00000000..4f476282 --- /dev/null +++ b/plugin.video.sky.sport.now/resources/lib/api.py @@ -0,0 +1,137 @@ +import hashlib + +import arrow + +from slyguy import userdata +from slyguy.session import Session +from slyguy.exceptions import Error + +from .language import _ +from .constants import API_URL, HEADERS, EPG_URL + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self._session = Session(HEADERS, base_url=API_URL) + self.logged_in = userdata.get('nllinktoken') != None + + def login(self, username, password): + self.logout() + + data = { + 'username': username, + 'password': password, + 'cookielink': 'true', + 'format': 'json', + } + + r = self._session.post('/secure/authenticate', data=data) + + data = r.json() + nllinktoken = r.cookies.get_dict().pop('nllinktoken', None) + code = data.get('code') or _.UNKNOWN_ERROR + + if code != 'loginsuccess' or not nllinktoken: + if code == 'failedgeo': + raise APIError(_.GEO_ERROR) + else: + raise APIError(_(_.LOGIN_ERROR, code=code)) + + userdata.set('nllinktoken', nllinktoken) + self.new_session() + + def deviceid(self): + return hashlib.sha1(userdata.get('username').encode('utf8')).hexdigest()[:8] + + def play(self, media_id, media_type, start=None, duration=None): + payload = { + 'id': media_id, + 'nt': 1, + 'type': media_type, + 'format': 'json', + 'drmtoken': True, + 'deviceid': self.deviceid(), + } + + if start: + payload['st'] = '{}000'.format(start) + + if duration: + payload['dur'] = '{}000'.format(duration) + + login_cookies = {'nllinktoken': userdata.get('nllinktoken'), 'UserName': userdata.get('username')} + data = self._session.post('/service/publishpoint', data=payload, cookies=login_cookies).json() + + if 'path' not in data: + code = data.get('code') or _.UNKNOWN_ERROR + if code == 'failedgeo': + raise APIError(_.GEO_ERROR) + else: + raise APIError(_(_.PLAYBACK_ERROR, code=code)) + + return data + + def schedule(self, date): + schedule = [] + + data = self._session.get(EPG_URL.format(date=date.format('YYYY/MM/DD'))).json() + for channel in data: + for event in channel['items']: + start = arrow.get(event['su']) + stop = start.shift(seconds=event['ds']) + duration = int(event['ds']) + title = event.get('e') + desc = event.get('ed') + schedule.append({'channel': channel['channelId'], 'start': start, 'stop': stop, 'duration': duration, 'title': title, 'desc': desc}) + + return sorted(schedule, key=lambda channel: channel['start']) + + def logout(self): + # self._session.post('service/logout', data={'format': 'json'}) + userdata.delete('nllinktoken') + self.new_session() + + def highlights(self, page=1, pagesize=100): + params = { + 'type': '0', + 'format': 'json', + 'ps': pagesize, + 'pn': page, + } + + data = self._session.get('/service/search', params=params).json() + + try: + code = data.get('code') + except: + code = None + + if code: + if code == 'failedgeo': + raise APIError(_.GEO_ERROR) + else: + raise APIError(_(_.PLAYBACK_ERROR, code=code or _.UNKNOWN_ERROR)) + + return data + + def channels(self): + params = { + 'format': 'json', + } + + data = self._session.get('/channels', params=params).json() + + try: + code = data.get('code') + except: + code = None + + if code: + if code == 'failedgeo': + raise APIError(_.GEO_ERROR) + else: + raise APIError(_(_.PLAYBACK_ERROR, code=code or _.UNKNOWN_ERROR)) + + return data \ No newline at end of file diff --git a/plugin.video.sky.sport.now/resources/lib/constants.py b/plugin.video.sky.sport.now/resources/lib/constants.py new file mode 100644 index 00000000..c818f509 --- /dev/null +++ b/plugin.video.sky.sport.now/resources/lib/constants.py @@ -0,0 +1,11 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36', +} + +API_URL = 'https://www.skysportnow.co.nz{}' +WIDEVINE_URL = 'https://shield-twoproxy.imggaming.com/proxy' +THUMB_URL = 'https://esliondsdoc.akamaized.net/mt/skyfanpass_v2/thumbs/{}' +EPG_URL = 'https://esliondsdoc.akamaized.net/mt/skyfanpass_v2/epg/{date}.json' + +MEDIA_CHANNEL = 'channel' +MEDIA_VIDEO = 'video' diff --git a/plugin.video.sky.sport.now/resources/lib/language.py b/plugin.video.sky.sport.now/resources/lib/language.py new file mode 100644 index 00000000..a6e4e900 --- /dev/null +++ b/plugin.video.sky.sport.now/resources/lib/language.py @@ -0,0 +1,18 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LIVE_TV = 30003 + HIGHLIGHTS = 30004 + PAGE_TITLE = 30005 + NEXT_PAGE = 30006 + GEO_ERROR = 30007 + PLAYBACK_ERROR = 30008 + UNKNOWN_ERROR = 30009 + LOGIN_ERROR = 30010 + REPLAY = 30011 + NOT_STARTED_YET = 30012 + EVENT_EXPIRED = 30013 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.sky.sport.now/resources/lib/plugin.py b/plugin.video.sky.sport.now/resources/lib/plugin.py new file mode 100644 index 00000000..7d55455a --- /dev/null +++ b/plugin.video.sky.sport.now/resources/lib/plugin.py @@ -0,0 +1,176 @@ +import codecs + +import arrow + +from slyguy import plugin, gui, userdata, signals, inputstream, settings +from slyguy.exceptions import PluginError + +from .api import API +from .language import _ +from .constants import WIDEVINE_URL, MEDIA_CHANNEL, MEDIA_VIDEO, THUMB_URL, HEADERS + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE_TV, _bold=True), path=plugin.url_for(live)) + folder.add_item(label=_(_.REPLAY, _bold=True), path=plugin.url_for(replay)) + folder.add_item(label=_(_.HIGHLIGHTS, _bold=True), path=plugin.url_for(highlights)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def live(**kwargs): + folder = plugin.Folder(_.LIVE_TV) + + for row in api.channels(): + folder.add_item( + label = row['name'], + art = {'thumb': THUMB_URL.format('channels/{id}_landscape.png'.format(id=row['id']))}, + playable = True, + path = plugin.url_for(play, media_id=row['id'], media_type=MEDIA_CHANNEL, _is_live=True), + ) + + return folder + +@plugin.route() +def replay(**kwargs): + folder = plugin.Folder(_.REPLAY) + + now = arrow.utcnow() + earliest = now.shift(hours=-24) + + dates = [now, earliest] + for date in dates: + for row in reversed(api.schedule(date)): + if row['start'] < earliest or row['start'] > now or row['stop'] > now: + continue + + icon = THUMB_URL.format('channels/{id}_landscape.png'.format(id=row['channel'])) + + item = plugin.Item( + label = u'{}: {}'.format(row['start'].to('local').humanize(), row['title']), + info = {'plot': row['desc'], 'duration': row['duration']}, + art = {'thumb': icon}, + path = plugin.url_for(play, media_id=row['channel'], media_type=MEDIA_CHANNEL, start=row['start'].timestamp, duration=row['duration']), + playable = True, + ) + + folder.add_items(item) + + return folder + +@plugin.route() +def highlights(page=1, **kwargs): + page = int(page) + folder = plugin.Folder(_.HIGHLIGHTS) + + data = api.highlights(page=page) + total_pages = int(data['paging']['totalPages']) + + if total_pages > 1: + folder.title += _(_.PAGE_TITLE, cur_page=page, total_pages=total_pages) + + for row in data['programs']: + try: + split = row['runtimeMins'].split(':') + duration = int(split[0]) * 60 + if len(split) > 1: + duration += int(split[1]) + except: + duration = 0 + + folder.add_item( + label = row['name'], + info = {'plot': row.get('description'), 'duration': duration}, + art = {'thumb': THUMB_URL.format(row.get('image', ''))}, + playable = True, + path = plugin.url_for(play, media_id=row['id'], media_type=MEDIA_VIDEO), + ) + + if page < total_pages: + folder.add_item( + label = _(_.NEXT_PAGE, next_page=page+1), + path = plugin.url_for(highlights, page=page+1), + ) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +@plugin.login_required() +def play(media_id, media_type, start=None, duration=None, **kwargs): + if start: + start = int(start) + now = arrow.utcnow() + if start > now.timestamp: + raise PluginError(_.NOT_STARTED_YET) + elif start < now.shift(hours=-24).timestamp: + raise PluginError(_.EVENT_EXPIRED) + + data = api.play(media_id, media_type, start, duration) + + headers = HEADERS + headers.update({'Authorization': 'bearer {}'.format(data['drmToken'])}) + + item = plugin.Item( + path = data['path'], + inputstream = inputstream.Widevine(license_key=WIDEVINE_URL), + headers = headers, + ) + + if media_type == MEDIA_CHANNEL: + item.inputstream.properties['manifest_update_parameter'] = 'full' + + return item + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for row in api.channels(): + thumb = THUMB_URL.format('channels/{id}_landscape.png'.format(id=row['id'])) + + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=row['id'], logo=thumb, name=row['name'], path=plugin.url_for(play, media_id=row['id'], media_type=MEDIA_CHANNEL, _is_live=True))) \ No newline at end of file diff --git a/plugin.video.sky.sport.now/resources/settings.xml b/plugin.video.sky.sport.now/resources/settings.xml new file mode 100644 index 00000000..fd93fd63 --- /dev/null +++ b/plugin.video.sky.sport.now/resources/settings.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.skygo.nz/.iptv_merge b/plugin.video.skygo.nz/.iptv_merge new file mode 100644 index 00000000..0e10c22b --- /dev/null +++ b/plugin.video.skygo.nz/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/SkyGo/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.skygo.nz/__init__.py b/plugin.video.skygo.nz/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.skygo.nz/addon.xml b/plugin.video.skygo.nz/addon.xml new file mode 100644 index 00000000..1eee5b0a --- /dev/null +++ b/plugin.video.skygo.nz/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Watch live channels from Sky Go (New Zealand). + +Subscription required. + true + + + + SkyGo new message + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.skygo.nz/default.py b/plugin.video.skygo.nz/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.skygo.nz/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.skygo.nz/fanart.jpg b/plugin.video.skygo.nz/fanart.jpg new file mode 100644 index 00000000..f74b8f9d Binary files /dev/null and b/plugin.video.skygo.nz/fanart.jpg differ diff --git a/plugin.video.skygo.nz/icon.png b/plugin.video.skygo.nz/icon.png new file mode 100644 index 00000000..c11729fb Binary files /dev/null and b/plugin.video.skygo.nz/icon.png differ diff --git a/plugin.video.skygo.nz/resources/__init__.py b/plugin.video.skygo.nz/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.skygo.nz/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.skygo.nz/resources/language/resource.language.en_gb/strings.po b/plugin.video.skygo.nz/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..4372dd69 --- /dev/null +++ b/plugin.video.skygo.nz/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,108 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Video not found" +msgstr "" + +msgctxt "#30001" +msgid "Email" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Unable to find that channel" +msgstr "" + +msgctxt "#30004" +msgid "Failed to login.\n" +"Server Message: {message}" +msgstr "" + +msgctxt "#30005" +msgid "This stream is currently protected by Adobe Access DRM and can not be played in Kodi\n" +"Sky should start to remove Adobe DRM in the near future" +msgstr "" + +msgctxt "#30006" +msgid "Error getting play token.\n" +"Server Message: {message}" +msgstr "" + +msgctxt "#30007" +msgid "Live TV" +msgstr "" + +msgctxt "#30010" +msgid "TV Shows" +msgstr "" + +msgctxt "#30011" +msgid "Movies" +msgstr "" + +msgctxt "#30012" +msgid "Sports" +msgstr "" + +msgctxt "#30013" +msgid "Box Sets" +msgstr "" + +msgctxt "#30014" +msgid "Unable to get content URL\n" +"Server Message: {message}" +msgstr "" + +msgctxt "#30015" +msgid "{title} - Episode {episode}" +msgstr "" + +msgctxt "#30018" +msgid "Hide Unplayable Content" +msgstr "" + +msgctxt "#30019" +msgid "{label} [COLOR red](LOCKED)[/COLOR]" +msgstr "" + +msgctxt "#30020" +msgid "{label} [COLOR red](ADOBE DRM)[/COLOR]" +msgstr "" + +msgctxt "#30021" +msgid "Next Page" +msgstr "" + +msgctxt "#30024" +msgid "Channels" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.skygo.nz/resources/lib/__init__.py b/plugin.video.skygo.nz/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.skygo.nz/resources/lib/api.py b/plugin.video.skygo.nz/resources/lib/api.py new file mode 100644 index 00000000..8b82ec3d --- /dev/null +++ b/plugin.video.skygo.nz/resources/lib/api.py @@ -0,0 +1,213 @@ +import time +import xml.etree.ElementTree as ET + +from slyguy import userdata +from slyguy.session import Session +from slyguy.log import log +from slyguy.exceptions import Error +from slyguy.util import strip_namespaces, jwt_data + +from .constants import HEADERS, API_URL, CHANNELS_URL, CONTENT_URL, PLAY_URL, WIDEVINE_URL +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + + ## Legacy ## + userdata.delete('pswd') + userdata.delete('access_token') + ############ + + self._session = Session(HEADERS, base_url=API_URL) + self._set_authentication() + + def _set_authentication(self): + token = userdata.get('sky_token') + if not token: + return + + self._session.cookies.update({'sky-access': token}) + self.logged_in = True + + def series(self, id): + return self._session.get(CONTENT_URL + id).json() + + def content(self, section='', text='', genre='', channels='', start=0): + params = { + 'genre': genre, + 'rating': '', + 'text': text, + 'sortBy': 'TITLE', + 'title': '', + 'lastChance': 'false', + 'type': '', + 'channel': channels, + 'section': section, + 'size': 200, + 'start': start, + } + + return self._session.get(CONTENT_URL, params=params).json() + + def channels(self): + return self._session.get(CHANNELS_URL).json()['entries'] + + def login(self, username, password): + session = self._session.get('/login/initSession').json() + + data = { + 'authType': 'signIn', + 'rememberMe': True, + 'sessionJwt': session['sessionJwt'], + 'username': username, + 'password': password, + } + + resp = self._session.post('/login/signin', json=data) + self._process_login(resp) + + def _process_login(self, resp): + data = resp.json() + + if not resp.ok or 'sky-access' not in resp.cookies: + raise APIError(_(_.LOGIN_ERROR, message=data.get('message'))) + + token = resp.cookies['sky-access'] + + userdata.set('sky_token', token) + userdata.set('device_id', data['deviceId']) + + if 'profileId' in data: + userdata.set('profile_id', data['profileId']) + + jwt = jwt_data(token) + userdata.set('token_expires', jwt['exp']) + + self._set_authentication() + self._get_play_token() + self._subscriptions() + + def _renew_token(self): + if time.time() < userdata.get('token_expires'): + return + + data = { + 'authType': 'renew', + 'deviceID': userdata.get('device_id'), + 'profileId': userdata.get('profile_id'), + 'rememberMe': True, + } + + resp = self._session.post('/login/renew', json=data) + self._process_login(resp) + + def _subscriptions(self): + data = self._session.get('/entitlements/v2/onlineSubscriptions', params={'profileId': userdata.get('profile_id')}).json() + userdata.set('subscriptions', data['onlineSubscriptions']) + + def _get_play_token(self): + params = { + 'profileId': userdata.get('profile_id'), + 'deviceId': userdata.get('device_id'), + 'partnerId': 'skygo', + 'description': 'undefined undefined undefined', + } + + resp = self._session.get('/mpx/v1/token', params=params) + data = resp.json() + + if not resp.ok or 'token' not in data: + raise APIError(_(_.TOKEN_ERROR, message=data.get('message'))) + + userdata.set('play_token', data['token']) + + def _concurrency_unlock(self, root): + concurrency_url = root.find("./head/meta[@name='concurrencyServiceUrl']").attrib['content'] + lock_id = root.find("./head/meta[@name='lockId']").attrib['content'] + lock_token = root.find("./head/meta[@name='lockSequenceToken']").attrib['content'] + lock = root.find("./head/meta[@name='lock']").attrib['content'] + + params = { + 'schema': '1.0', + 'form': 'JSON', + '_clientId': 'playerplayerHTML', + '_id': lock_id, + '_sequenceToken': lock_token, + '_encryptedLock': lock, + 'httpError': False, + } + + return self._session.get('{}/web/Concurrency/unlock'.format(concurrency_url), params=params).json() + + def play_media(self, id): + self._renew_token() + + params = { + 'form': 'json', + 'types': None, + 'fields': 'id,content', + 'byId': id, + } + + data = self._session.get(PLAY_URL, params=params).json() + + if not data['entries']: + raise APIError(_.VIDEO_UNAVAILABLE) + + videos = data['entries'][0]['media$content'] + + chosen = videos[0] + for video in videos: + if video['plfile$format'].upper() == 'MPEG-DASH': + chosen = video + break + + if chosen['plfile$format'].upper() == 'F4M': + raise APIError(_.ADOBE_ERROR) + + params = { + 'auth': userdata.get('play_token'), + 'formats': 'mpeg-dash', + 'tracking': True, + 'format': 'SMIL' + } + + resp = self._session.get(chosen['plfile$url'], params=params) + + root = ET.fromstring(resp.text) + strip_namespaces(root) + + if root.find("./body/seq/ref/param[@name='exception']") != None: + error_msg = root.find("./body/seq/ref").attrib.get('abstract') + raise APIError(_(_.PLAY_ERROR, message=error_msg)) + + try: + data = self._concurrency_unlock(root) + except Exception as e: + log.debug('Failed to get concurrency lock. Attempting to continue without it...') + log.exception(e) + + ref = root.find(".//switch/ref") + url = ref.attrib['src'] + + tracking = {} + for item in ref.find("./param[@name='trackingData']").attrib['value'].split('|'): + key, value = item.split('=') + tracking[key] = value + + license = WIDEVINE_URL.format(token=userdata.get('play_token'), pid=tracking['pid'], challenge='B{SSM}') + + return url, license + + def logout(self): + userdata.delete('device_id') + userdata.delete('profile_id') + userdata.delete('play_token') + userdata.delete('token_expires') + userdata.delete('sky_token') + userdata.delete('subscriptions') + self.new_session() \ No newline at end of file diff --git a/plugin.video.skygo.nz/resources/lib/constants.py b/plugin.video.skygo.nz/resources/lib/constants.py new file mode 100644 index 00000000..aa61bf29 --- /dev/null +++ b/plugin.video.skygo.nz/resources/lib/constants.py @@ -0,0 +1,52 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36 sky-android (ver=1.0)', + 'sky-x-forwarded-for': 'test', + 'X-Forwarded-For': '202.89.4.222', +} + +API_URL = 'https://www.skygo.co.nz/pro-api{}' +CONTENT_URL = 'https://www.skygo.co.nz/pub-api/content/v1/' +CHANNELS_URL = 'https://feed.theplatform.com/f/7tMqSC/O5wnnwnQqDWV?form=json' +IMAGE_URL = 'https://prod-images.skygo.co.nz/{}' +PLAY_URL = 'https://feed.theplatform.com/f/7tMqSC/0_V4XPWsMSE9' +WIDEVINE_URL = 'https://widevine.entitlement.theplatform.com/wv/web/ModularDrm/getWidevineLicense?schema=1.0&token={token}&form=json&account=http://access.auth.theplatform.com/data/Account/2682481291&_releasePid={pid}&_widevineChallenge={challenge}' + +OLD_MESSAGE = 'Skygo appears to have switched off their older API this add-on uses.\nThis results in live channels no longer work\nVOD seems to still work.. for now\n\n[B]I have created a new add-on (Sky Go New) based on their new api[/B]\n[B]It is availabe now in the SlyGuy repo[/B]\n\nCurrently it only supports live channels\nBut it has more channels, 2 hour rewind and higher quality!\nVOD and IPTV Merge support should be added to it over the next few weeks' + +GENRES = { + 'tvshows': [ + ['All Shows', ''], + ['Drama', 'drama'], + ['Kids & Family', 'children'], + ['Comedy', 'comedy'], + ['Action', 'action'], + ['Animated', 'animated'], + ['Reality', 'reality'], + ['Documentary', 'documentary'], + ['Food & Lifestyle', 'lifestyle'], + ['General Entertainment', 'general_entertainment'], + ], + 'movies': [ + ['All Movies', ''], + ['Drama', 'drama'], + ['Comedy', 'comedy'], + ['Action', 'action'], + ['Animated', 'animated'], + ['Thriller', 'thriller'], + ['Kids & Family', 'family'], + ['Documentary/Factual', 'documentary'], + ], + 'sport': [ + ['All Sports', ''], + ['Motor Sport', 'motorsport'], + ['Basketball', 'basketball'], + ['Golf', 'golf'], + ['Cricket', 'cricket'], + ['Rugby', 'rugby'], + ['League', 'league'], + ['Football', 'football'], + ['Netball', 'netball'], + ['Other', 'other'], + ], +} +GENRES['boxsets'] = GENRES['tvshows'] \ No newline at end of file diff --git a/plugin.video.skygo.nz/resources/lib/language.py b/plugin.video.skygo.nz/resources/lib/language.py new file mode 100644 index 00000000..fb0edb09 --- /dev/null +++ b/plugin.video.skygo.nz/resources/lib/language.py @@ -0,0 +1,28 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + VIDEO_UNAVAILABLE = 30000 + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + NO_CHANNEL = 30003 + LOGIN_ERROR = 30004 + ADOBE_ERROR = 30005 + TOKEN_ERROR = 30006 + LIVE_TV = 30007 + + TV_SHOWS = 30010 + MOVIES = 30011 + SPORTS = 30012 + BOX_SETS = 30013 + PLAY_ERROR = 30014 + EPISODE_LABEL = 30015 + + + HIDE_UNPLAYABLE = 30018 + LOCKED = 30019 + ADOBE_DRM = 30020 + NEXT_PAGE = 30021 + + CHANNELS = 30024 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.skygo.nz/resources/lib/plugin.py b/plugin.video.skygo.nz/resources/lib/plugin.py new file mode 100644 index 00000000..aa465ec1 --- /dev/null +++ b/plugin.video.skygo.nz/resources/lib/plugin.py @@ -0,0 +1,299 @@ +import codecs +from string import ascii_uppercase + +from kodi_six import xbmcplugin + +from slyguy import plugin, gui, userdata, signals, inputstream, settings +from slyguy.exceptions import Error +from slyguy.constants import ROUTE_LIVE_TAG + +from .api import API +from .constants import * +from .language import _ + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE_TV, _bold=True), path=plugin.url_for(live_tv)) + folder.add_item(label=_(_.TV_SHOWS, _bold=True), path=plugin.url_for(content, label=_.TV_SHOWS, section='tvshows')) + folder.add_item(label=_(_.MOVIES, _bold=True), path=plugin.url_for(content, label=_.MOVIES, section='movies')) + folder.add_item(label=_(_.SPORTS, _bold=True), path=plugin.url_for(content, label=_.SPORTS, section='sport')) + folder.add_item(label=_(_.BOX_SETS, _bold=True), path=plugin.url_for(content, label=_.BOX_SETS, section='boxsets')) + folder.add_item(label=_(_.CHANNELS, _bold=True), path=plugin.url_for(channels)) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +def _is_subscribed(subscriptions, categories): + if not subscriptions or not categories: + return True + + for row in categories: + if row['media$scheme'] == 'urn:sky:subscription' and row['media$name'] not in subscriptions: + return False + + return True + +def _get_image(row): + images = row.get('media$thumbnails') + + if not images: + images = row.get('media$content') + + if not images: + return None + + for row in images: + if 'SkyGoChannelLogoScroll' in row['plfile$assetTypes'] or 'SkyGOChannelLogo' in row['plfile$assetTypes']: + return row['plfile$streamingUrl'] + + return images[-1]['plfile$streamingUrl'] + +def _get_channels(only_live=True): + subscriptions = userdata.get('subscriptions', []) + channels = [] + rows = api.channels() + + for row in sorted(rows, key=lambda r: float(r.get('sky$liveChannelOrder', 'inf'))): + if only_live and 'Live' not in row.get('sky$channelType', []): + continue + + label = row['title'] + + subscribed = _is_subscribed(subscriptions, row.get('media$categories')) + + if not subscribed: + label = _(_.LOCKED, label=label) + + if settings.getBool('hide_unplayable', False) and not subscribed: + continue + + if label.lower().startswith('entpopup'): + label = row.get('description', label) + + channels.append({ + 'label': label, + 'title': row['title'], + 'channel': row.get('sky$skyGOChannelID', ''), + 'plot': row.get('description'), + 'image': _get_image(row), + 'path': plugin.url_for(play, id=row['id'], _is_live=True), + }) + + return channels + +@plugin.route() +def live_tv(**kwargs): + folder = plugin.Folder(_.LIVE_TV) + + for row in _get_channels(only_live=True): + folder.add_item( + label = row['label'], + info = {'plot': row.get('plot')}, + art = {'thumb': row['image']}, + path = row['path'], + playable = True, + ) + + return folder + +@plugin.route() +def channels(**kwargs): + folder = plugin.Folder(_.CHANNELS) + + for row in _get_channels(only_live=False): + folder.add_item( + label = row['label'], + info = {'plot': row.get('plot')}, + art = {'thumb': row['image']}, + path = plugin.url_for(content, label=row['title'], channels=row['channel']), + ) + + return folder + +@plugin.route() +def content(label, section='', genre=None, channels='', start=0, **kwargs): + start = int(start) + folder = plugin.Folder(label) + + if section and genre is None: + genres = GENRES.get(section, []) + + for row in genres: + folder.add_item(label=row[0], path=plugin.url_for(content, label=row[0], section=section, genre=row[1])) + + if genres: + return folder + + data = api.content(section, genre=genre, channels=channels, start=start) + items = _process_content(data['data']) + folder.add_items(items) + + if items and data['index'] < data['available']: + folder.add_item( + label = _(_.NEXT_PAGE, _bold=True), + path = plugin.url_for(content, label=label, section=section, genre=genre, channels=channels, start=data['index']), + specialsort = 'bottom', + ) + + return folder + +@plugin.route() +def search(query=None, start=0, **kwargs): + start = int(start) + + if not query: + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + + data = api.content(text=query, start=start) + items = _process_content(data['data']) + folder.add_items(items) + + if items and data['index'] < data['available']: + folder.add_item( + label = _(_.NEXT_PAGE, _bold=True), + path = plugin.url_for(search, query=query, start=data['index']), + ) + + return folder + +def _process_content(rows): + items = [] + subscriptions = userdata.get('subscriptions', []) + + for row in rows: + if row['suspended']: + continue + + label = row['title'] + + if 'subCode' in row and subscriptions and row['subCode'] not in subscriptions: + label = _(_.LOCKED, label=label) + + if settings.getBool('hide_unplayable', False): + continue + + if row['type'] == 'movie': + items.append(plugin.Item( + label = label, + info = { + 'plot': row.get('synopsis'), + 'duration': int(row.get('duration', '0 mins').strip(' mins')) * 60, + 'mediatype': 'movie', + }, + art = {'thumb': IMAGE_URL.format(row['images'].get('MP',''))}, + path = plugin.url_for(play, id=row['mediaId']), + playable = True, + )) + + elif row['type'] == 'season': + items.append(plugin.Item( + label = label, + art = {'thumb': IMAGE_URL.format(row['images'].get('MP',''))}, + path = plugin.url_for(series, id=row['id']), + )) + + return items + +@plugin.route() +def series(id, **kwargs): + data = api.series(id) + + folder = plugin.Folder(data['title'], fanart=IMAGE_URL.format(data['images'].get('PS','')), sort_methods=[xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_UNSORTED, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED]) + + for row in data.get('subContent', []): + folder.add_item( + label = row['episodeTitle'], + info = { + 'tvshowtitle': data.get('seriesTitle', data['title']), + 'plot': row.get('episodeSynopsis'), + 'duration': int(row.get('duration', '0 mins').strip(' mins')) * 60, + 'season': int(row.get('seasonNumber', 0)), + 'episode': int(row.get('episodeNumber', 0)), + 'mediatype': 'episode', + }, + art = {'thumb': IMAGE_URL.format(data['images'].get('MP',''))}, + path = plugin.url_for(play, id=row['mediaId']), + playable = True, + ) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +@plugin.login_required() +def play(id, **kwargs): + url, license = api.play_media(id) + + item = plugin.Item( + path = url, + headers = HEADERS, + inputstream = inputstream.Widevine( + license_key = license, + challenge = '', + content_type = '', + response = 'JBlicense', + ), + ) + + if kwargs.get(ROUTE_LIVE_TAG): + item.inputstream.properties['manifest_update_parameter'] = 'full' + gui.text(OLD_MESSAGE) + + return item + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for row in _get_channels(only_live=True): + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-chno="{channel}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=row['channel'], channel=row['channel'], name=row['label'], logo=row['image'], path=row['path'])) \ No newline at end of file diff --git a/plugin.video.skygo.nz/resources/settings.xml b/plugin.video.skygo.nz/resources/settings.xml new file mode 100644 index 00000000..fe13a2b4 --- /dev/null +++ b/plugin.video.skygo.nz/resources/settings.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.spark.sport/.iptv_merge b/plugin.video.spark.sport/.iptv_merge new file mode 100644 index 00000000..fe2b8a31 --- /dev/null +++ b/plugin.video.spark.sport/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/SparkSport/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.spark.sport/__init__.py b/plugin.video.spark.sport/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.spark.sport/addon.xml b/plugin.video.spark.sport/addon.xml new file mode 100644 index 00000000..4686826a --- /dev/null +++ b/plugin.video.spark.sport/addon.xml @@ -0,0 +1,24 @@ + + + + + + + video + + + + Watch your favourite sports live & on demand with Spark Sport. + +Subscription required. + true + + + + Add Search + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.spark.sport/default.py b/plugin.video.spark.sport/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.spark.sport/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.spark.sport/fanart.jpg b/plugin.video.spark.sport/fanart.jpg new file mode 100644 index 00000000..953d53e1 Binary files /dev/null and b/plugin.video.spark.sport/fanart.jpg differ diff --git a/plugin.video.spark.sport/icon.png b/plugin.video.spark.sport/icon.png new file mode 100644 index 00000000..ec18492b Binary files /dev/null and b/plugin.video.spark.sport/icon.png differ diff --git a/plugin.video.spark.sport/resources/__init__.py b/plugin.video.spark.sport/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.spark.sport/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.spark.sport/resources/language/resource.language.en_gb/strings.po b/plugin.video.spark.sport/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..d1a95e04 --- /dev/null +++ b/plugin.video.spark.sport/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,137 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Failed to login.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30004" +msgid "Channels" +msgstr "" + +msgctxt "#30005" +msgid "Featured" +msgstr "" + +msgctxt "#30006" +msgid "No data returned from Spark (iStreamPlanet)\n" +"This is usually caused by geo-block (non NZ IP address)\n" +"Also try logging out and back in" +msgstr "" + +msgctxt "#30007" +msgid "Live & Upcoming" +msgstr "" + +msgctxt "#30008" +msgid "Sports" +msgstr "" + +msgctxt "#30009" +msgid " [ON NOW]" +msgstr "" + +msgctxt "#30010" +msgid " (D MMM, h:mm A)" +msgstr "" + +msgctxt "#30011" +msgid "Could not retrieve asset\n" +"This is usually caused by geo-block (non NZ IP address)" +msgstr "" + +msgctxt "#30012" +msgid "Could not retrieve MPD url" +msgstr "" + +msgctxt "#30013" +msgid "Could not retrieve entitlement token\n" +"Reason: {error}" +msgstr "" + +msgctxt "#30014" +msgid "Failed to refresh authentication token\n" +"Try logging out and back in\n" +"Server Message: {msg}" +msgstr "" + + + +msgctxt "#30020" +msgid "Set Reminder" +msgstr "" + +msgctxt "#30021" +msgid "Remove Reminder" +msgstr "" + +msgctxt "#30022" +msgid "Freemium" +msgstr "" + +msgctxt "#30023" +msgid "Reminder Set" +msgstr "" + +msgctxt "#30024" +msgid "Reminder Removed" +msgstr "" + +msgctxt "#30025" +msgid "{event} has just started" +msgstr "" + +msgctxt "#30026" +msgid "Watch" +msgstr "" + +msgctxt "#30027" +msgid "Close" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" \ No newline at end of file diff --git a/plugin.video.spark.sport/resources/lib/__init__.py b/plugin.video.spark.sport/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.spark.sport/resources/lib/api.py b/plugin.video.spark.sport/resources/lib/api.py new file mode 100644 index 00000000..a179225a --- /dev/null +++ b/plugin.video.spark.sport/resources/lib/api.py @@ -0,0 +1,215 @@ +import uuid +import time +from contextlib import contextmanager + +import arrow + +from slyguy import userdata +from slyguy.log import log +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.mem_cache import cached +from slyguy.util import jwt_data + +from .constants import HEADERS, DEFAULT_TOKEN, UUID_NAMESPACE, API_BASE, WV_LICENSE_URL +from .language import _ + +class APIError(Error): + pass + + +class API(object): + def new_session(self): + self.logged_in = False + + self._session = Session(HEADERS, base_url=API_BASE) + self._set_authentication() + + def _set_authentication(self): + token = userdata.get('token') + if not token: + return + + self._session.headers.update({'Authorization': 'Bearer {}'.format(token)}) + self.logged_in = True + + @contextmanager + def api_call(self): + if self.logged_in: + self.refresh_token() + + try: + yield + except Exception as e: + log.exception(e) + raise APIError(_.NO_DATA) + + def refresh_token(self): + if time.time() < userdata.get('expires', 0): + return + + data = self._session.put('/oam/v2/user/tokens').json() + + if 'errorMessage' in data: + raise APIError(_(_.TOKEN_ERROR, msg=data['errorMessage'])) + + self._set_token(data['sessionToken']) + + def _set_token(self, token): + data = jwt_data(token) + expires = min(int(time.time()+86400), data['exp']-10) + + userdata.set('expires', expires) + userdata.set('token', token) + + self._set_authentication() + + def login(self, username, password): + self.logout() + + deviceid = str(uuid.uuid3(uuid.UUID(UUID_NAMESPACE), str(username))) + + payload = { + "username": username, + "password": password, + "deviceID": deviceid, + } + + headers = {'Authorization': 'Bearer {}'.format(DEFAULT_TOKEN)} + + with self.api_call(): + data = self._session.post('/oam/v2/user/tokens', json=payload, headers=headers).json() + + if 'errorMessage' in data: + raise APIError(_(_.LOGIN_ERROR, msg=data['errorMessage'])) + + userdata.set('deviceid', deviceid) + + self._set_token(data['sessionToken']) + + def whats_on(self, query=''): + now = arrow.utcnow() + later = now.shift(days=21) + + params = { + 'count': 100, + 'offset': 0, + 'language': '*', + 'query': query, + 'sort': 'startTime', + 'sortOrder': 'asc', + 'startTime.lte': later.format('YYYY-MM-DDTHH:mm:ss.000') + 'Z', + 'endTime.gte': now.format('YYYY-MM-DDTHH:mm:ss.000') + 'Z', + 'types': 'live/competitions,live/teamCompetitions,live/events', + } + + with self.api_call(): + return self._session.get('/ocm/v2/search', params=params).json()['results'] + + def search(self, query): + params = { + 'count': 100, + 'offset': 0, + 'language': '*', + 'query': query, + 'sort': 'liveEventDate', + 'sortOrder': 'desc', + 'searchMethods': 'prefix,fuzzy', + 'types': 'vod/competitions,vod/teamCompetitions,vod/events', + } + + with self.api_call(): + return self._session.get('/ocm/v2/search', params=params).json()['results'] + + def sparksport(self): + with self.api_call(): + return self._session.get('https://d2rhrqdzx7i00p.cloudfront.net/sparksport2').json() + + def page(self, page_id): + with self.api_call(): + return self._session.get('/ocm/v4/pages/{}'.format(page_id)).json() + + @cached(expires=60*10) + def section(self, section_id): + with self.api_call(): + return self._session.get('/ocm/v4/sections/{}'.format(section_id)).json() + + def live_channels(self): + with self.api_call(): + return self._session.get('/ocm/v2/epg/stations').json()['epg/stations'] + + def entitiy(self, entity_id): + with self.api_call(): + data = self._session.get('/ocm/v2/entities/{}'.format(entity_id)).json() + + for key in data: + try: + if data[key][0]['id'] == entity_id: + return data[key][0] + except (TypeError, KeyError): + continue + + return None + + def play(self, entity_id): + entity = self.entitiy(entity_id) + if not entity or not entity.get('assetIDs'): + raise APIError(_.NO_ASSET_ERROR) + + with self.api_call(): + assets = self._session.get('/ocm/v2/assets/{}'.format(entity['assetIDs'][0])).json()['assets'] + + mpd_url = None + for asset in assets: + try: + urls = asset['liveURLs'] or asset['vodURLs'] + mpd_url = urls['dash']['primary'] + backup = urls['dash'].get('backup') + if 'dai.google.com' in mpd_url and backup and 'dai.google.com' not in backup: + mpd_url = backup + except (TypeError, KeyError): + continue + else: + break + + if not mpd_url: + raise APIError(_.NO_MPD_ERROR) + + # Hack until Spark fix their bad second base-url + # if '/startover/' in mpd_url: + # mpd_url = mpd_url.split('/') + # mpd_url = "/".join(mpd_url[:-3]) + '/master.mpd' + # + + payload = { + 'assetID': entity_id, + 'playbackUrl': mpd_url, + 'deviceID': userdata.get('deviceid'), + } + + data = self._session.post('/oem/v2/entitlement?tokentype=isp-atlas', json=payload).json() + token = data.get('entitlementToken') + + if not token: + raise APIError(_(_.NO_ENTITLEMENT, error=data.get('errorMessage'))) + + params = { + 'progress': 0, + 'device': userdata.get('deviceid'), + } + + self._session.put('/oxm/v1/streams/{}/stopped'.format(entity_id), params=params) + + headers = {'X-ISP-TOKEN': token} + + from_start = True + if entity.get('customAttributes', {}).get('isLinearChannelInLiveEvent') == 'true': + from_start = False + + return mpd_url, WV_LICENSE_URL, headers, from_start + + def logout(self): + userdata.delete('token') + userdata.delete('deviceid') + userdata.delete('expires') + self.new_session() \ No newline at end of file diff --git a/plugin.video.spark.sport/resources/lib/constants.py b/plugin.video.spark.sport/resources/lib/constants.py new file mode 100644 index 00000000..6a441621 --- /dev/null +++ b/plugin.video.spark.sport/resources/lib/constants.py @@ -0,0 +1,13 @@ +HEADERS = { + 'User-Agent': 'Spark Sport/0.1.1-0 (Linux;Android 8.1.0) ExoPlayerLib/2.9.2', + 'X-Forwarded-For': '202.89.4.222', #Just for istreamplanet api browsing. playback (akamai) requires real NZ IP address +} + +API_BASE = 'https://platform.prod.dtc.istreamplanet.net{}' +WV_LICENSE_URL = 'https://widevine.license.istreamplanet.com/widevine/api/license/0f6160eb-bbd3-4c70-8e4d-0d485e7cb055' +IMG_URL = 'https://res.cloudinary.com/istreamplanet/image/upload/spark-prod/{}' + +UUID_NAMESPACE = '124f1611-0232-4336-be43-e054c8ecd0d5' +DEFAULT_TOKEN = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYXBpIiwidWlkIjoiIiwiYW5vbiI6ZmFsc2UsInBlcm1pc3Npb25zIjpudWxsLCJhcGlLZXkiOiIzODgyZGQ0My1lZGMwLTQ1YzktYTk5My05YWU0MzRhMWJlNjAiLCJvcmlnaW5hbFRlbmFudCI6IiIsImV4cCI6MzExNTY0NTQ0MiwiaWF0IjoxNTM4ODQ1NDQyLCJpc3MiOiJPcmJpcy1PQU0tVjEiLCJzdWIiOiIzODgyZGQ0My1lZGMwLTQ1YzktYTk5My05YWU0MzRhMWJlNjAifQ.E5Kos46Qp6YPh5-t6cqLf854i2IAEQEZ_MNDQDBcEKzQpGXY3RGAjG1-pe9qeQZOaqHq8OoyVIXiHyYg0tGllw' + +SERVICE_TIME = 270 \ No newline at end of file diff --git a/plugin.video.spark.sport/resources/lib/language.py b/plugin.video.spark.sport/resources/lib/language.py new file mode 100644 index 00000000..872856ba --- /dev/null +++ b/plugin.video.spark.sport/resources/lib/language.py @@ -0,0 +1,28 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LOGIN_ERROR = 30003 + CHANNELS = 30004 + FEATURED = 30005 + NO_DATA = 30006 + WHATS_ON = 30007 + SPORTS = 30008 + LIVE = 30009 + DATE_FORMAT = 30010 + NO_ASSET_ERROR = 30011 + NO_MPD_ERROR = 30012 + NO_ENTITLEMENT = 30013 + TOKEN_ERROR = 30014 + + SET_REMINDER = 30020 + REMOVE_REMINDER = 30021 + FREEMIUM = 30022 + REMINDER_SET = 30023 + REMINDER_REMOVED = 30024 + EVENT_STARTED = 30025 + WATCH = 30026 + CLOSE = 30027 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.spark.sport/resources/lib/plugin.py b/plugin.video.spark.sport/resources/lib/plugin.py new file mode 100644 index 00000000..a9357c7d --- /dev/null +++ b/plugin.video.spark.sport/resources/lib/plugin.py @@ -0,0 +1,298 @@ +import codecs + +import arrow + +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.exceptions import PluginError +from slyguy.constants import PLAY_FROM_TYPES, PLAY_FROM_ASK, PLAY_FROM_LIVE, PLAY_FROM_START, ROUTE_LIVE_TAG + +from .api import API +from .language import _ +from .constants import IMG_URL + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.FEATURED, _bold=True), path=plugin.url_for(featured)) + folder.add_item(label=_(_.WHATS_ON, _bold=True), path=plugin.url_for(whats_on)) + folder.add_item(label=_(_.SPORTS, _bold=True), path=plugin.url_for(sports)) + folder.add_item(label=_(_.CHANNELS, _bold=True), path=plugin.url_for(channels)) + folder.add_item(label=_(_.FREEMIUM, _bold=True), path=plugin.url_for(page, page_id='FREEMIUM')) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def featured(**kwargs): + folder = plugin.Folder(_.FEATURED) + folder.add_items(_page('HOME')['items']) + return folder + +@plugin.route() +def search(query=None, **kwargs): + if not query: + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + + # rows = api.whats_on(query) + # items = _process_rows(rows) + + rows = api.search(query) + folder.add_items(_process_rows(rows)) + + return folder + +@plugin.route() +def sports(**kwargs): + folder = plugin.Folder(_.SPORTS) + + info = api.sparksport() + + for row in info['sports']: + page_info = info['pageInformation'].get(row['id']) + if not page_info or row['isHidden']: + continue + + folder.add_item( + label = row['name'], + art = {'thumb': IMG_URL.format(row['images']['channelLogo'])}, + path = plugin.url_for(page, page_id=page_info['RAILS_V3_PAGE_ID']), + ) + + return folder + +@plugin.route() +def whats_on(**kwargs): + folder = plugin.Folder(_.WHATS_ON) + rows = api.whats_on() + folder.add_items(_process_rows(rows)) + return folder + +def _process_rows(rows): + items = [] + alerts = userdata.get('alerts', []) + now = arrow.utcnow() + + for row in rows: + try: + thumb = IMG_URL.format(row.get('pictureID') or row['pictures']['16x9']) + except: + thumb = None + + description = row.get('shortDescription') or None + + start_time = arrow.get(row.get('startTime') or None) + end_time = arrow.get(row.get('endTime') or None) + now = arrow.utcnow() + + item = plugin.Item( + label = row['name'], + info = {'plot': description}, + art = {'thumb': thumb}, + path = plugin.url_for(play, id=row['id']), + playable = True, + is_folder = False, + ) + + if row.get('resourceType') == 'epg/stations': + item.path = plugin.url_for(play, id=row['id'], _is_live=True) + + elif start_time < now and end_time > now: + item.label += _(_.LIVE, _bold=True) + + if row.get('customAttributes', {}).get('isLinearChannelInLiveEvent') != 'true': + item.context.append((_.PLAY_FROM_LIVE, "PlayMedia({})".format( + plugin.url_for(play, id=row['id'], play_type=PLAY_FROM_LIVE, _is_live=True) + ))) + + item.context.append((_.PLAY_FROM_START, "PlayMedia({})".format( + plugin.url_for(play, id=row['id'], play_type=PLAY_FROM_START, _is_live=True) + ))) + + item.path = plugin.url_for(play, id=row['id'], play_type=settings.getEnum('live_play_type', PLAY_FROM_TYPES, PLAY_FROM_ASK), _is_live=True) + + elif start_time > now.shift(seconds=10): + item.label += start_time.to('local').format(_.DATE_FORMAT) + item.path = plugin.url_for(alert, asset=row['id'], title=row['name']) + item.playable = False + + if row['id'] not in alerts: + item.info['playcount'] = 0 + else: + item.info['playcount'] = 1 + + items.append(item) + + return items + +def _page(page_id): + items = [] + + page_data = api.page(page_id) + for row in page_data['sections']: + #flatten + if len(page_data['sections']) == 1 and row.get('title') and row.get('items'): + return {'title': row['title'], 'items': _process_rows(row['items'])} + + data = api.section(row['id']) + if not data.get('items'): + continue + + item = plugin.Item( + label = data['title'], + path = plugin.url_for(section, section_id=row['id']), + ) + + items.append(item) + + return {'title':page_data['title'], 'items':items} + +@plugin.route() +def alert(asset, title, **kwargs): + alerts = userdata.get('alerts', []) + + if asset not in alerts: + alerts.append(asset) + gui.notification(title, heading=_.REMINDER_SET) + else: + alerts.remove(asset) + gui.notification(title, heading=_.REMINDER_REMOVED) + + userdata.set('alerts', alerts) + gui.refresh() + +@plugin.route() +def page(page_id, **kwargs): + data = _page(page_id) + + folder = plugin.Folder(data['title']) + folder.add_items(data['items']) + + return folder + +@plugin.route() +def section(section_id, **kwargs): + data = api.section(section_id) + folder = plugin.Folder(data['title']) + folder.add_items(_process_rows(data['items'])) + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +@plugin.login_required() +def play(id, play_type=PLAY_FROM_LIVE, **kwargs): + mpd_url, license, headers, from_start = api.play(id) + + item = plugin.Item( + path = mpd_url, + inputstream = inputstream.Widevine(license_key=license), + headers = headers, + use_proxy = True, + ) + + play_type = int(play_type) + if from_start and (play_type == PLAY_FROM_START or (play_type == PLAY_FROM_ASK and not gui.yes_no(_.PLAY_FROM, yeslabel=_.PLAY_FROM_LIVE, nolabel=_.PLAY_FROM_START))): + item.properties['ResumeTime'] = 1 + item.properties['TotalTime'] = 1 + + return item + +@plugin.route() +def channels(**kwargs): + folder = plugin.Folder(_.CHANNELS) + + rows = api.live_channels() + folder.add_items(_process_rows(rows)) + + return folder + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@signals.on(signals.ON_SERVICE) +def service(): + api.refresh_token() + alerts = userdata.get('alerts', []) + if not alerts: + return + + now = arrow.now() + notify = [] + _alerts = [] + + for id in alerts: + entity = api.entitiy(id) + if not entity: + continue + + start = arrow.get(entity.get('startTime')) + + if now > start and (now - start).total_seconds() <= 60*10: + notify.append(entity) + elif now < start: + _alerts.append(id) + + userdata.set('alerts', _alerts) + + for entity in notify: + if not gui.yes_no(_(_.EVENT_STARTED, event=entity['name']), yeslabel=_.WATCH, nolabel=_.CLOSE): + continue + + with signals.throwable(): + play(id=entity['id'], play_type=settings.getEnum('live_play_type', LIVE_PLAY_TYPES, default=FROM_CHOOSE)) + +@plugin.route() +@plugin.merge() +@plugin.login_required() +def playlist(output, **kwargs): + channels = api.live_channels() + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(u'#EXTM3U\n') + + for channel in channels: + f.write(u'#EXTINF:-1 tvg-id="{id}" tvg-name="{name}" tvg-logo="{logo}",{name}\n{path}\n'.format( + id=channel['id'], name=channel['name'], logo=IMG_URL.format(channel['pictureID']), + path=plugin.url_for(play, id=channel['id'], _is_live=True))) \ No newline at end of file diff --git a/plugin.video.spark.sport/resources/settings.xml b/plugin.video.spark.sport/resources/settings.xml new file mode 100644 index 00000000..7e729be0 --- /dev/null +++ b/plugin.video.spark.sport/resources/settings.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.spark.sport/service.py b/plugin.video.spark.sport/service.py new file mode 100644 index 00000000..ef90c79f --- /dev/null +++ b/plugin.video.spark.sport/service.py @@ -0,0 +1,5 @@ +from slyguy.service import run + +from resources.lib.constants import SERVICE_TIME + +run(SERVICE_TIME) \ No newline at end of file diff --git a/plugin.video.stan.au/__init__.py b/plugin.video.stan.au/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.stan.au/addon.xml b/plugin.video.stan.au/addon.xml new file mode 100644 index 00000000..ef9856d7 --- /dev/null +++ b/plugin.video.stan.au/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Stan offers unlimited access to thousands of hours of entertainment, first-run exclusives, award-winning TV shows, blockbuster movies and kids content. + +Subscription required. + true + + + + Fix sport minis / highlights + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.stan.au/default.py b/plugin.video.stan.au/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.stan.au/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.stan.au/fanart.jpg b/plugin.video.stan.au/fanart.jpg new file mode 100644 index 00000000..5abccede Binary files /dev/null and b/plugin.video.stan.au/fanart.jpg differ diff --git a/plugin.video.stan.au/icon.png b/plugin.video.stan.au/icon.png new file mode 100644 index 00000000..3ec596f1 Binary files /dev/null and b/plugin.video.stan.au/icon.png differ diff --git a/plugin.video.stan.au/resources/__init__.py b/plugin.video.stan.au/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.stan.au/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.stan.au/resources/language/resource.language.en_gb/strings.po b/plugin.video.stan.au/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..3249ae15 --- /dev/null +++ b/plugin.video.stan.au/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,180 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Failed to login / refresh token.\n" +"Message: {msg}" +msgstr "" + + +msgctxt "#30005" +msgid "Stan detected your not using an Australian IP address or may be using a VPN" +msgstr "" + + +msgctxt "#30009" +msgid "TV" +msgstr "" + +msgctxt "#30010" +msgid "Movies" +msgstr "" + +msgctxt "#30011" +msgid "Kids" +msgstr "" + +msgctxt "#30012" +msgid "Featured" +msgstr "" + +msgctxt "#30013" +msgid "Search: {query} ({page})" +msgstr "" + +msgctxt "#30014" +msgid "Next Page ({next})" +msgstr "" + +msgctxt "#30015" +msgid "Failed to get playback URL.\n" +"Message: {msg}" +msgstr "" + +msgctxt "#30016" +msgid "Trailers & Extras" +msgstr "" + +msgctxt "#30017" +msgid "Add Profile" +msgstr "" + +msgctxt "#30018" +msgid "Delete Profile" +msgstr "" + +msgctxt "#30019" +msgid "Select Profile" +msgstr "" + +msgctxt "#30020" +msgid "Profile Activated" +msgstr "" + +msgctxt "#30021" +msgid "Select profile to delete" +msgstr "" + +msgctxt "#30022" +msgid "All history for this profile will be lost. Are you sure you want to permanently delete this profile?" +msgstr "" + +msgctxt "#30023" +msgid "Delete {name}'s profile?" +msgstr "" + +msgctxt "#30024" +msgid "Profile Deleted" +msgstr "" + +msgctxt "#30025" +msgid "Random Pick" +msgstr "" + +msgctxt "#30026" +msgid "{label} (USED)" +msgstr "" + +msgctxt "#30027" +msgid "Select Avatar" +msgstr "" + +msgctxt "#30028" +msgid "Profile Name" +msgstr "" + +msgctxt "#30029" +msgid "Kids Profile?" +msgstr "" + +msgctxt "#30030" +msgid "Kid friendly interface with only content suitable for kids." +msgstr "" + +msgctxt "#30031" +msgid "Disable profile switching in Kids profile" +msgstr "" + +msgctxt "#30032" +msgid "{name} is already being used" +msgstr "" + +msgctxt "#30033" +msgid "You're currently signed into a Stan Kids profile and can't access this content." +msgstr "" + +msgctxt "#30034" +msgid "My List" +msgstr "" + +msgctxt "#30035" +msgid "Continue Watching" +msgstr "" + +msgctxt "#30036" +msgid "[B]Go to {series}[/B]" +msgstr "" + +msgctxt "#30037" +msgid "Sport" +msgstr "" + +msgctxt "#30038" +msgid "Hide Sport" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" \ No newline at end of file diff --git a/plugin.video.stan.au/resources/lib/__init__.py b/plugin.video.stan.au/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.stan.au/resources/lib/api.py b/plugin.video.stan.au/resources/lib/api.py new file mode 100644 index 00000000..25792756 --- /dev/null +++ b/plugin.video.stan.au/resources/lib/api.py @@ -0,0 +1,363 @@ +import time +import hmac +import hashlib +import base64 +import json + +from six.moves.urllib_parse import quote_plus +from kodi_six import xbmc +from pycaption import detect_format, WebVTTWriter + +from slyguy import userdata, settings +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.log import log +from slyguy.util import cenc_init, jwt_data + +from .constants import * +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + self._session = Session(HEADERS, base_url=API_URL) + self._set_authentication() + + def _set_authentication(self): + self.logged_in = userdata.get('token') != None + + def nav_items(self, key): + data = self.page('sitemap') + + for row in data['navs']['browse']: + if row['path'] == '/'+key: + return row['items'] + + return [] + + def page(self, key): + return self.url('/pages/v6/{}.json'.format(key)) + + def url(self, url): + self._check_token() + + params = { + 'feedTypes': 'posters,landscapes,hero', + 'jwToken': userdata.get('token'), + } + + return self._session.get(url, params=params).json() + + def search(self, query, page=1, limit=50): + self._check_token() + + params = { + 'q': query, + 'limit': limit, + 'offset': (page-1)*limit, + 'jwToken': userdata.get('token'), + } + + if userdata.get('profile_kids', False): + url = '/search/v12/kids/search' + else: + url = '/search/v12/search' + + return self._session.get(url, params=params).json() + + def login(self, username, password): + self.logout() + + payload = { + 'email': username, + 'password': password, + 'rnd': str(int(time.time())), + 'stanName': 'Stan-Android', + 'type': 'mobile', + 'os': 'Android', + 'stanVersion': STAN_VERSION, + # 'clientId': '', + # 'model': '', + # 'sdk': '', + # 'manufacturer': '', + } + + payload['sign'] = self._get_sign(payload) + + self._login('/login/v1/sessions/mobile/account', payload) + + def _check_token(self, force=False): + if not force and userdata.get('expires') > time.time(): + return + + params = { + 'type': 'mobile', + 'os': 'Android', + 'stanVersion': STAN_VERSION, + } + + payload = { + 'jwToken': userdata.get('token'), + } + + self._login('/login/v1/sessions/mobile/app', payload, params) + + def _login(self, url, payload, params=None): + data = self._session.post(url, data=payload, params=params).json() + + if 'errors' in data: + try: + msg = data['errors'][0]['code'] + if msg == 'Streamco.Login.VPNDetected': + msg = _.IP_ADDRESS_ERROR + except: + msg = '' + + raise APIError(_(_.LOGIN_ERROR, msg=msg)) + + userdata.set('token', data['jwToken']) + userdata.set('expires', int(time.time() + (data['renew'] - data['now']) - 30)) + userdata.set('user_id', data['userId']) + + userdata.set('profile_id', data['profile']['id']) + userdata.set('profile_name', data['profile']['name']) + userdata.set('profile_icon', data['profile']['iconImage']['url']) + userdata.set('profile_kids', int(data['profile'].get('isKidsProfile', False))) + + self._set_authentication() + + try: log.debug('Token Data: {}'.format(json.dumps(jwt_data(userdata.get('token'))))) + except: pass + + def watchlist(self): + self._check_token() + + params = { + 'jwToken': userdata.get('token'), + } + + url = '/watchlist/v1/users/{user_id}/profiles/{profile_id}/watchlistitems'.format(user_id=userdata.get('user_id'), profile_id=userdata.get('profile_id')) + return self._session.get(url, params=params).json() + + def history(self, program_ids=None): + self._check_token() + + params = { + 'jwToken': userdata.get('token'), + 'limit': 100, + } + + if program_ids: + params['programIds'] = program_ids + + url = '/history/v1/users/{user_id}/profiles/{profile_id}/history'.format(user_id=userdata.get('user_id'), profile_id=userdata.get('profile_id')) + return self._session.get(url, params=params).json() + + # def resume_series(self, series_id): + # params = { + # 'jwToken': userdata.get('token'), + # } + + # url = '/resume/v1/users/{user_id}/profiles/{profile_id}/resumeSeries/{series_id}'.format(user_id=userdata.get('user_id'), profile_id=userdata.get('profile_id'), series_id=series_id) + # return self._session.get(url, params=params).json() + + # def resume_program(self, program_id): + # params = { + # 'jwToken': userdata.get('token'), + # } + + # url = '/resume/v1/users/{user_id}/profiles/{profile_id}/resume/{program_id}'.format(user_id=userdata.get('user_id'), profile_id=userdata.get('profile_id'), program_id=program_id) + # return self._session.get(url, params=params).json() + + def set_profile(self, profile_id): + self._check_token() + + params = { + 'type': 'mobile', + 'os': 'Android', + 'stanVersion': STAN_VERSION, + } + + payload = { + 'jwToken': userdata.get('token'), + 'profileId': profile_id, + } + + self._login('/login/v1/sessions/mobile/app', payload, params) + + def profiles(self): + self._check_token() + + params = { + 'jwToken': userdata.get('token'), + } + + return self._session.get('/accounts/v1/users/{user_id}/profiles'.format(user_id=userdata.get('user_id')), params=params).json() + + def add_profile(self, name, icon_set, icon_index, kids=False): + self._check_token() + + payload = { + 'jwToken': userdata.get('token'), + 'name': name, + 'isKidsProfile': kids, + 'iconSet': icon_set, + 'iconIndex': icon_index, + } + + return self._session.post('/accounts/v1/users/{user_id}/profiles'.format(user_id=userdata.get('user_id')), data=payload).json() + + def delete_profile(self, profile_id): + self._check_token() + + params = { + 'jwToken': userdata.get('token'), + 'profileId': profile_id, + } + + return self._session.delete('/accounts/v1/users/{user_id}/profiles'.format(user_id=userdata.get('user_id')), params=params).ok + + def profile_icons(self): + self._check_token() + + params = { + 'jwToken': userdata.get('token'), + } + + return self._session.get('/accounts/v1/accounts/icons', params=params).json() + + def program(self, program_id): + self._check_token() + + params = { + 'jwToken': userdata.get('token'), + } + + if userdata.get('profile_kids', False): + url = '/cat/v12/kids/programs/{program_id}.json' + else: + url = '/cat/v12/programs/{program_id}.json' + + return self._session.get(url.format(program_id=program_id), params=params).json() + + def play(self, program_id): + self._check_token(force=True) + + program_data = self.program(program_id) + if 'errors' in program_data: + try: + msg = program_data['errors'][0]['code'] + if msg == 'Streamco.Concurrency.OutOfRegion': + msg = _.IP_ADDRESS_ERROR + elif msg == 'Streamco.Catalogue.NOT_SAFE_FOR_KIDS': + msg = _.KIDS_PLAY_DENIED + except: + msg = '' + + raise APIError(_(_.PLAYBACK_ERROR, msg=msg)) + + jw_token = userdata.get('token') + + params = { + 'programId': program_id, + 'jwToken': jw_token, + 'format': 'dash', + 'capabilities.drm': 'widevine', + 'quality': 'high', + } + + data = self._session.get('/concurrency/v1/streams', params=params).json() + + if 'errors' in data: + try: + msg = data['errors'][0]['code'] + if msg == 'Streamco.Concurrency.OutOfRegion': + msg = _.IP_ADDRESS_ERROR + except: + msg = '' + + raise APIError(_(_.PLAYBACK_ERROR, msg=msg)) + + play_data = data['media'] + play_data['drm']['init_data'] = self._init_data(play_data['drm']['keyId']) + play_data['videoUrl'] = API_URL.format('/manifest/v1/dash/androidtv.mpd?url={url}&audioType=all&version=88'.format( + url = quote_plus(play_data['videoUrl']), + )) + + params = { + 'form': 'json', + 'schema': '1.0', + 'jwToken': jw_token, + '_id': data['concurrency']['lockID'], + '_sequenceToken': data['concurrency']['lockSequenceToken'], + '_encryptedLock': 'STAN', + } + + self._session.get('/concurrency/v1/unlock', params=params).json() + + return program_data, play_data + + def _init_data(self, key): + key = key.replace('-', '') + key_len = '{:x}'.format(len(bytearray.fromhex(key))) + key = '12{}{}'.format(key_len, key) + key = bytearray.fromhex(key) + + return cenc_init(key) + + def get_subtitle(self, url, out_file): + r = self._session.get(url) + reader = detect_format(r.text) + vtt = WebVTTWriter().write(reader().read(r.text)) + with open(out_file, 'wb') as f: + f.write(vtt.encode('utf8')) + + def _get_sign(self, payload): + module_version = 214 + + f3757a = bytearray((144, 100, 149, 1, 2, 8, 36, 208, 209, 51, 103, 131, 240, 66, module_version, + 20, 195, 170, 44, 194, 17, 161, 118, 71, 105, 42, 76, 116, 230, 87, 227, 40, 115, + 5, 62, 199, 66, 7, 251, 125, 238, 123, 71, 220, 179, 29, 165, 136, 16, module_version, + 117, 10, 100, 222, 41, 60, 103, 2, 121, 130, 217, 75, 220, 100, 59, 35, 193, 22, 117, + 27, 74, 50, 85, 40, 39, 31, 180, 81, 34, 155, 172, 202, 71, 162, 202, 234, 91, 176, 199, 207, 131, + 229, 125, 105, 9, 227, 188, 234, 61, 33, 17, 113, 222, 173, 182, 120, 34, 80, 135, 219, 8, 97, 176, 62, + 137, 126, 222, 139, 136, 77, 243, 37, 11, 234, 82, 244, 222, 44)) + + f3758b = bytearray((120, 95, 52, 175, 139, 155, 151, 35, 39, 184, 141, 27, 55, 215, 102, 173, 2, 37, 141, 164, 236, 217, + 173, 194, 94, 67, 195, 24, 221, 66, 233, 11, 226, 91, 33, 249, 225, 54, 88, 54, 118, 101, 31, 248, 11, + 208, 206, 226, 68, 20, 143, 37, 104, 159, 184, 22, 53, 179, 104, 152, 170, 29, 26, 6, 163, 45, 87, 193, + 136, 226, 128, 245, 231, 238, 154, 211, 71, 134, 232, 99, 35, 54, 170, 128, 1, 218, 249, 70, 182, 145, + 125, 211, 16, 43, 118, 177, 64, 128, 111, 73, 234, 22, 21, 165, 67, 23, 15, 5, 11, 70, 48, 97, 134, 185, + 11, 28, 167, 140, 123, 81, 240, 247, 77, 187, 23, 243, 89, 54)) + + msg = '' + for key in sorted(payload.keys()): + if msg: msg += '&' + msg += key + '=' + quote_plus(payload[key], safe="_-!.~'()*") + + bArr = bytearray(len(f3757a)) + for i in range(len(bArr)): + bArr[i] = f3757a[i] ^ f3758b[i] + + bArr2 = bytearray(int(len(bArr)/2)) + for i in range(len(bArr2)): + bArr2[i] = bArr[i] ^ bArr[len(bArr2) + i] + + signature = hmac.new(bArr2, msg=msg.encode('utf8'), digestmod=hashlib.sha256).digest() + + return base64.b64encode(signature).decode('utf8') + + def logout(self): + userdata.delete('token') + userdata.delete('expires') + userdata.delete('user_id') + + userdata.delete('profile_id') + userdata.delete('profile_icon') + userdata.delete('profile_name') + userdata.delete('profile_kids') + + self.new_session() \ No newline at end of file diff --git a/plugin.video.stan.au/resources/lib/constants.py b/plugin.video.stan.au/resources/lib/constants.py new file mode 100644 index 00000000..fac78d81 --- /dev/null +++ b/plugin.video.stan.au/resources/lib/constants.py @@ -0,0 +1,11 @@ +HEADERS = { + 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 8.1.0; MI 5 Build/OPM7.181005.003)', +} + +API_URL = 'https://api.stan.com.au{}' + +AUDIO_2CH = 'aac' +AUDIO_6CH = 'aac,ac3,eac,eac3' +AUDIO_QUALITY = [AUDIO_2CH, AUDIO_6CH] + +STAN_VERSION = '4.2.2.40832' \ No newline at end of file diff --git a/plugin.video.stan.au/resources/lib/language.py b/plugin.video.stan.au/resources/lib/language.py new file mode 100644 index 00000000..8114a6b4 --- /dev/null +++ b/plugin.video.stan.au/resources/lib/language.py @@ -0,0 +1,41 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LOGIN_ERROR = 30003 + + IP_ADDRESS_ERROR = 30005 + + TV = 30009 + MOVIES = 30010 + KIDS = 30011 + FEATURED = 30012 + SEARCH_FOR = 30013 + NEXT_PAGE = 30014 + PLAYBACK_ERROR = 30015 + TRAILERS_EXTRAS = 30016 + ADD_PROFILE = 30017 + DELETE_PROFILE = 30018 + SELECT_PROFILE = 30019 + PROFILE_ACTIVATED = 30020 + SELECT_DELETE_PROFILE = 30021 + DELETE_PROFILE_INFO = 30022 + DELTE_PROFILE_HEADER = 30023 + PROFILE_DELETED = 30024 + RANDOM_AVATAR = 30025 + AVATAR_USED = 30026 + SELECT_AVATAR = 30027 + PROFILE_NAME = 30028 + KIDS_PROFILE = 30029 + KIDS_PROFILE_INFO = 30030 + KID_LOCKDOWN = 30031 + PROFILE_NAME_TAKEN = 30032 + KIDS_PLAY_DENIED = 30033 + MY_LIST = 30034 + CONTINUE_WATCHING = 30035 + GOTO_SERIES = 30036 + SPORT = 30037 + HIDE_SPORT = 30038 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.stan.au/resources/lib/plugin.py b/plugin.video.stan.au/resources/lib/plugin.py new file mode 100644 index 00000000..5fb2a29c --- /dev/null +++ b/plugin.video.stan.au/resources/lib/plugin.py @@ -0,0 +1,535 @@ +import random + +import arrow +from kodi_six import xbmcplugin + +from slyguy import plugin, gui, userdata, signals, inputstream, settings +from slyguy.log import log +from slyguy.exceptions import PluginError +from slyguy.constants import ROUTE_LIVE_SUFFIX, ROUTE_LIVE_TAG, PLAY_FROM_TYPES, PLAY_FROM_ASK, PLAY_FROM_LIVE, PLAY_FROM_START + +from .api import API +from .language import _ +from .constants import HEADERS + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def index(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + if not userdata.get('profile_kids', False): + folder.add_item(label=_(_.FEATURED, _bold=True), path=plugin.url_for(featured, key='sitemap', title=_.FEATURED)) + folder.add_item(label=_(_.TV, _bold=True), path=plugin.url_for(nav, key='tv', title=_.TV)) + folder.add_item(label=_(_.MOVIES, _bold=True), path=plugin.url_for(nav, key='movies', title=_.MOVIES)) + + if not settings.getBool('hide_sport', False): + folder.add_item(label=_(_.SPORT, _bold=True), path=plugin.url_for(nav, key='sport', title=_.SPORT)) + + folder.add_item(label=_(_.KIDS, _bold=True), path=plugin.url_for(nav, key='kids', title=_.KIDS)) + folder.add_item(label=_(_.MY_LIST, _bold=True), path=plugin.url_for(my_list)) + folder.add_item(label=_(_.CONTINUE_WATCHING, _bold=True), path=plugin.url_for(continue_watching)) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + if not userdata.get('kid_lockdown', False): + folder.add_item(label=_.SELECT_PROFILE, path=plugin.url_for(select_profile), art={'thumb': userdata.get('profile_icon')}, info={'plot': userdata.get('profile_name')}, _kiosk=False, bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + _select_profile() + gui.refresh() + +@plugin.route() +def my_list(**kwargs): + folder = plugin.Folder(_.MY_LIST) + + data = api.watchlist() + + for row in data['entries']: + if row['programType'] == 'series': + folder.add_item( + label = row['title'], + art = {'thumb': _art(row['images']), 'fanart': _art(row['images'], 'fanart')}, + path = plugin.url_for(series, series_id=row['programId']), + ) + elif row['programType'] == 'movie': + folder.add_item( + label = row['title'], + art = {'thumb': _art(row['images']), 'fanart': _art(row['images'], 'fanart')}, + path = plugin.url_for(play, program_id=row['programId']), + playable = True, + ) + + return folder + +@plugin.route() +def continue_watching(**kwargs): + folder = plugin.Folder(_.CONTINUE_WATCHING) + + data = api.history() + + for row in data['entries']: + if row['completed'] or not row['position']: + continue + + if row['programType'] == 'movie': + folder.add_item( + label = row['title'], + properties = {'ResumeTime': row['position'], 'TotalTime': row['totalDuration']}, + art = {'thumb': _art(row['images']), 'fanart': _art(row['images'], 'fanart')}, + path = plugin.url_for(play, program_id=row['programId']), + playable = True, + ) + elif row['programType'] == 'episode': + folder.add_item( + label = row['title'], + properties = {'ResumeTime': row['position'], 'TotalTime': row['totalDuration']}, + art = {'thumb': _art(row['images']), 'fanart': _art(row['images'], 'fanart')}, + info = {'tvshowtitle': row['seriesTitle'], 'mediatype': 'episode', 'season': row['tvSeasonNumber'], 'episode': row['tvSeasonEpisodeNumber']}, + context = ((_(_.GOTO_SERIES, series=row['seriesTitle']), 'Container.Update({})'.format(plugin.url_for(series, series_id=row['seriesId']))),), + path = plugin.url_for(play, program_id=row['programId']), + playable = True, + ) + + return folder + +@plugin.route() +@plugin.login_required() +def select_profile(**kwargs): + if userdata.get('kid_lockdown', False): + return + + _select_profile() + gui.refresh() + +def _select_profile(): + profiles = api.profiles() + + options = [] + values = [] + can_delete = [] + default = -1 + + for index, profile in enumerate(profiles): + values.append(profile) + options.append(plugin.Item(label=profile['name'], art={'thumb': profile['iconImage']['url']})) + + if profile['id'] == userdata.get('profile_id'): + default = index + + elif not profile['isPrimary']: + can_delete.append(profile) + + options.append(plugin.Item(label=_(_.ADD_PROFILE, _bold=True))) + values.append('_add') + + if can_delete: + options.append(plugin.Item(label=_(_.DELETE_PROFILE, _bold=True))) + values.append('_delete') + + index = gui.select(_.SELECT_PROFILE, options=options, preselect=default, useDetails=True) + if index < 0: + return + + selected = values[index] + + if selected == '_delete': + _delete_profile(can_delete) + elif selected == '_add': + _add_profile(taken_names=[x['name'].lower() for x in profiles], taken_avatars=[x['iconImage']['url'] for x in profiles]) + else: + _set_profile(selected) + +def _delete_profile(profiles): + options = [] + for index, profile in enumerate(profiles): + options.append(plugin.Item(label=profile['name'], art={'thumb': profile['iconImage']['url']})) + + index = gui.select(_.SELECT_DELETE_PROFILE, options=options, useDetails=True) + if index < 0: + return + + selected = profiles[index] + if gui.yes_no(_.DELETE_PROFILE_INFO, heading=_(_.DELTE_PROFILE_HEADER, name=selected['name'])) and api.delete_profile(selected['id']): + gui.notification(_.PROFILE_DELETED, heading=selected['name'], icon=selected['iconImage']['url']) + +def _add_profile(taken_names, taken_avatars): + ## PROFILE AVATAR ## + options = [plugin.Item(label=_(_.RANDOM_AVATAR, _bold=True)),] + values = [['_random',None],] + avatars = [] + unused = [] + + for icon_set in api.profile_icons(): + for row in icon_set['icons']: + icon_info = [icon_set['iconSet'], row['iconIndex']] + + values.append(icon_info) + avatars.append(icon_info) + + if row['iconImage'] in taken_avatars: + label = _(_.AVATAR_USED, label=icon_set['label']) + else: + label = icon_set['label'] + unused.append(icon_info) + + options.append(plugin.Item(label=label, art={'thumb': row['iconImage']})) + + index = gui.select(_.SELECT_AVATAR, options=options, useDetails=True) + if index < 0: + return + + avatar = values[index] + if avatar[0] == '_random': + avatar = random.choice(unused or avatars) + + ## PROFLE KIDS ## + kids = gui.yes_no(_.KIDS_PROFILE_INFO, heading=_.KIDS_PROFILE) + + ## PROFILE NAME ## + name = '' + while True: + name = gui.input(_.PROFILE_NAME, default=name).strip() + if not name: + return + + elif name.lower() in taken_names: + gui.notification(_(_.PROFILE_NAME_TAKEN, name=name)) + + else: + break + + ## ADD PROFILE ## + profile = api.add_profile(name, icon_set=avatar[0], icon_index=avatar[1], kids=kids) + if 'message' in profile: + raise PluginError(profile['message']) + + _set_profile(profile) + +def _set_profile(profile, notify=True): + api.set_profile(profile['id']) + + if settings.getBool('kid_lockdown', False) and profile['isKidsProfile']: + userdata.set('kid_lockdown', True) + + if notify: + gui.notification(_.PROFILE_ACTIVATED, heading=userdata.get('profile_name'), icon=userdata.get('profile_icon')) + +@plugin.route() +def nav(key, title, **kwargs): + folder = plugin.Folder(title) + + rows = api.nav_items(key) + if not rows: + return folder + + folder.add_item( + label = _.FEATURED, + path = plugin.url_for(featured, key=key, title=title), + ) + + for row in rows: + folder.add_item( + label = row['title'], + #art = {'thumb': row['image']}, + path = plugin.url_for(parse, url=row['url'], title=row['title']), + ) + + return folder + +@plugin.route() +def parse(url, title=None, **kwargs): + data = api.url(url) + folder = plugin.Folder(title or data.get('title')) + + if data.get('type') == 'section': + for row in data['entries']: + if row['type'] != 'hero' and not row.get('hideTitle', False): + folder.add_item( + label = row['title'], + #art = {'thumb': row['thumbnail']}, + path = plugin.url_for(parse, url=row['url'], title=row['title']), + ) + + return folder + + if data.get('type') == 'single_list': + data = api.url(data['entries'][0]['url']) + + items = _process_entries(data['entries']) + folder.add_items(items) + + return folder + +@plugin.route() +def featured(key, title, **kwargs): + folder = plugin.Folder(title) + + data = api.page(key) + + for row in data['entries']: + if row['type'] in ('posters', 'landscapes') and not row.get('hideTitle', False): + folder.add_item( + label = row['title'], + #art = {'thumb': row.get('thumbnail')}, + path = plugin.url_for(parse, url=row['url'], title=row['title']), + ) + + return folder + +@plugin.route() +def search(query=None, page=1, **kwargs): + page = int(page) + limit = 50 + + if not query: + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query, page=page)) + + data = api.search(query, page=page, limit=limit) + + items = _process_entries(data['entries']) + folder.add_items(items) + + if len(data['entries']) == limit: + folder.add_item( + label = _(_.NEXT_PAGE, next=page+1, _bold=True), + path = plugin.url_for(search, query=query, page=page+1), + ) + + return folder + +def _art(images, type='thumb'): + if type == 'fanart': + keys = ['Banner-L0', 'Banner-L1', 'Banner-L2'] + elif type == 'episode': + keys = ['Cast in Character', 'Scene Still', 'Poster Art', 'Box Art'] + else: + keys = ['Landscape', 'Poster Art', 'Box Art', 'Scene Still', 'Cast in Character'] + + for key in keys: + if key in images: + return images[key]['url'] + + return None + +def _process_entries(entries): + items = [] + + now = arrow.now() + play_type = settings.getEnum('live_play_type', PLAY_FROM_TYPES, default=PLAY_FROM_ASK) + + for row in entries: + if row.get('type') in ('posters', 'landscapes') and not row.get('hideTitle', False): + items.append(plugin.Item( + label = row['title'], + #art = {'thumb': row.get('thumbnail')}, + path = plugin.url_for(parse, url=row['url'], title=row['title']), + )) + elif row.get('programType') == 'series': + items.append(plugin.Item( + label = row['title'], + art = {'thumb': _art(row['images']), 'fanart': _art(row['images'], 'fanart')}, + info = { + 'plot': row['description'], + 'year': row['releaseYear'], + }, + path = plugin.url_for(series, series_id=row['id']), + )) + + elif row.get('programType') == 'movie': + item = plugin.Item( + label = row['title'], + info = { + 'plot': row['description'], + 'year': row['releaseYear'], + 'duration': row['runtime'], + 'mediatype': 'movie', + }, + art = {'thumb': _art(row['images']), 'fanart': _art(row['images'], 'fanart')}, + playable = True, + path = _get_play_path(program_id=row['id']), + ) + + if row.get('liveStartDate'): + is_live = False + start_date = arrow.get(int(row['liveStartDate'])/1000) + + if row.get('liveEndDate'): + end_date = arrow.get(int(row['liveEndDate']/1000)) + else: + end_date = start_date + + if start_date > now: + item.label += ' [{}]'.format(start_date.humanize()) + elif start_date < now and end_date > now: + is_live = True + item.label += ' [B][LIVE][/B]' + + if 'episode' in row: + program_id = row['episode']['id'] + if row['episode']['bonusFeature']: + item.info['duration'] = None + else: + program_id = row['id'] + + item.path = _get_play_path(program_id=program_id, play_type=play_type, _is_live=is_live) + + if is_live: + item.context.append((_.PLAY_FROM_LIVE, "PlayMedia({})".format( + _get_play_path(program_id=row['id'], play_type=PLAY_FROM_LIVE, _is_live=is_live) + ))) + + item.context.append((_.PLAY_FROM_START, "PlayMedia({})".format( + _get_play_path(program_id=row['id'], play_type=PLAY_FROM_START, _is_live=is_live) + ))) + + items.append(item) + + return items + +@plugin.route() +def series(series_id, **kwargs): + data = api.program(series_id) + + fanart = _art(data['images'], 'fanart') + folder = plugin.Folder(data['title'], fanart=fanart) + + for row in sorted(data['seasons'], key=lambda x: x['seasonNumber']): + if row.get('bonusFeature'): + row['title'] = _.TRAILERS_EXTRAS + + folder.add_item( + label = row['title'], + art = {'thumb': _art(data['images'])}, + path = plugin.url_for(episodes, url=row['url'], show_title=data['title'], fanart=fanart), + ) + + return folder + +def _get_play_path(**kwargs): + profile_id = userdata.get('profile_id', '') + if profile_id: + kwargs['profile_id'] = profile_id + + return plugin.url_for(play, **kwargs) + +@plugin.route() +def episodes(url, show_title, fanart, **kwargs): + data = api.url(url) + + extras = data.get('bonusFeature', False) + + folder = plugin.Folder(show_title, fanart=fanart) + if not extras: + folder.sort_methods = [xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_UNSORTED, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED] + + for row in data['entries']: + if row['programType'] == 'episode': + if not row['images']: + try: row = api.url(row['url']) + except: pass + + folder.add_item( + label = row['title'], + info = { + 'plot': row['description'], + 'year': row['releaseYear'], + 'duration': row['runtime'], + 'season': row['tvSeasonNumber'] if not extras else None, + 'episode': row['tvSeasonEpisodeNumber'] if not extras else None, + 'mediatype': 'episode', + 'tvshowtitle': show_title, + }, + art = {'thumb': _art(row['images'], type='episode')}, + playable = True, + path = _get_play_path(program_id=row['id']), + ) + + return folder + +@plugin.route() +@plugin.plugin_callback() +def subs(url, _data_path, **kwargs): + api.get_subtitle(url, _data_path) + return _data_path + +@plugin.route() +def play(program_id, play_type=None, **kwargs): + return _play(program_id, play_type, is_live=ROUTE_LIVE_TAG in kwargs) + +def _play(program_id, play_type=None, is_live=False): + play_type = int(play_type) if play_type else None + program_data, play_data = api.play(program_id) + + headers = HEADERS.copy() + headers['dt-custom-data'] = play_data['drm']['customData'] + + item = plugin.Item( + path = play_data['videoUrl'], + headers = headers, + inputstream = inputstream.Widevine( + license_key = play_data['drm']['licenseServerUrl'], + license_data = play_data['drm']['init_data'], + response = 'JBlicense', + ), + ) + + if is_live and (play_type == PLAY_FROM_START or (play_type == PLAY_FROM_ASK and not gui.yes_no(_.PLAY_FROM, yeslabel=_.PLAY_FROM_LIVE, nolabel=_.PLAY_FROM_START))): + item.properties['ResumeTime'] = '1' + item.properties['TotalTime'] = '1' + + item.proxy_data['subtitles'] = [] + for row in play_data.get('captions', []): + ## need to proxy so their timings are fixed + item.proxy_data['subtitles'].append(['text/vtt', row['language'], plugin.url_for(subs, url=row['url'])]) + + # for chapter in program_data.get('chapters', []): + # if chapter['name'] == 'Intro': + # item.properties['TotalTime'] = item.properties['ResumeTime'] = str(chapter['end']/1000 - 1) + # elif chapter['name'] == 'Credits': + # item.play_next = {'time': chapter['start']/1000} + + return item + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + userdata.delete('kid_lockdown') + gui.refresh() \ No newline at end of file diff --git a/plugin.video.stan.au/resources/settings.xml b/plugin.video.stan.au/resources/settings.xml new file mode 100644 index 00000000..11792a8e --- /dev/null +++ b/plugin.video.stan.au/resources/settings.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.superview/__init__.py b/plugin.video.superview/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.superview/addon.xml b/plugin.video.superview/addon.xml new file mode 100644 index 00000000..28fd7a1a --- /dev/null +++ b/plugin.video.superview/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Watch all Australia Supercars Championship races Live and On Demand. + +Subscription required. + true + + + + Better re-login process + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.superview/default.py b/plugin.video.superview/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.superview/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.superview/fanart.jpg b/plugin.video.superview/fanart.jpg new file mode 100644 index 00000000..114e812d Binary files /dev/null and b/plugin.video.superview/fanart.jpg differ diff --git a/plugin.video.superview/icon.png b/plugin.video.superview/icon.png new file mode 100644 index 00000000..3a6ac0a4 Binary files /dev/null and b/plugin.video.superview/icon.png differ diff --git a/plugin.video.superview/resources/__init__.py b/plugin.video.superview/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.superview/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.superview/resources/language/resource.language.en_gb/strings.po b/plugin.video.superview/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..36d28ebd --- /dev/null +++ b/plugin.video.superview/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,142 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Email" +msgstr "" + +msgctxt "#30001" +msgid "Password" +msgstr "" + +msgctxt "#30002" +msgid "Races" +msgstr "" + +msgctxt "#30003" +msgid "Could not find that race" +msgstr "" + +msgctxt "#30004" +msgid "No streaming is available yet" +msgstr "" + +msgctxt "#30005" +msgid "Your user session has expired.\n" +"You need to logout and login again.\n" +"Tip: Enable [B]Save Password[/B] in settings for auto-login when session expires" +msgstr "" + +msgctxt "#30006" +msgid "Save Password" +msgstr "" + +msgctxt "#30007" +msgid "You need re-login after enabling Save Password" +msgstr "" + +msgctxt "#30008" +msgid "No Races" +msgstr "" + +msgctxt "#30009" +msgid "You have not bought the pass for this season" +msgstr "" + +msgctxt "#30010" +msgid "{title} (LIVE)" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32034" +msgid "General" +msgstr "" + +msgctxt "#32035" +msgid "Playback" +msgstr "" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "" + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "" + +msgctxt "#32023" +msgid "Use Inputstream HLS" +msgstr "" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "" + +msgctxt "#32036" +msgid "Advanced" +msgstr "" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "" + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" diff --git a/plugin.video.superview/resources/lib/__init__.py b/plugin.video.superview/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.superview/resources/lib/api.py b/plugin.video.superview/resources/lib/api.py new file mode 100644 index 00000000..178fef09 --- /dev/null +++ b/plugin.video.superview/resources/lib/api.py @@ -0,0 +1,174 @@ +from collections import OrderedDict + +from bs4 import BeautifulSoup + +from slyguy import util, userdata, settings, gui +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy import mem_cache + +from .constants import * +from .language import _ + +class APIError(Error): + pass + +class API(object): + def __init__(self): + self.new_session() + + def new_session(self): + self.logged_in = True if userdata.get('_cookies') else False + if not settings.getBool('save_password', False): + userdata.delete(PASSWORD_KEY) + + if self.logged_in and settings.getBool('save_password', True) and not userdata.get(PASSWORD_KEY): + self.logout() + gui.ok(_.SAVE_PASSWORD_RELOGIN) + + def _get(self, url, attempt=1): + cookies = {'cookie_notice_accepted': 'true'} + cookies.update(userdata.get('_cookies')) + + r = Session().get(BASE_URL+url, timeout=20, cookies=cookies, headers=HEADERS) + + password = userdata.get(PASSWORD_KEY) + if 'membersignin' in r.text and password and attempt <= 3: + self.login(userdata.get('username'), password) + return self._get(url, attempt=attempt+1) + + if 'membersignin' in r.text: + raise APIError(_.SESSION_EXPIRED) + + return r + + @mem_cache.cached(RACES_CACHE_TIME) + def races(self): + races = OrderedDict() + + r = self._get('superview-videos/') + + if 'Buy now' in r.text: + raise APIError(_.NOT_PAID) + + elif 'Full Race Replays' not in r.text: + return races + + split = r.text.split('Full Race Replays') + upcoming = BeautifulSoup(split[0], 'html.parser') + replays = BeautifulSoup(split[1], 'html.parser') + + for elem in upcoming.find_all('span', {'class': 'resultsummary-tabletitle-inner'}): + race = self._process_race(elem, upcoming=True) + races[race['slug']] = race + + for elem in reversed(replays.find_all('span', {'class': 'resultsummary-tabletitle-inner'})): + race = self._process_race(elem) + if race['slug'] not in races: + races[race['slug']] = race + + return races + + def _process_race(self, elem, upcoming=False): + race = { + 'title': elem.get_text(), + 'streams': [], + 'upcoming': upcoming, + } + + race['slug'] = race['title'].lower().strip().replace(' ', '-') + + rows = elem.parent.find_next_sibling('div', {'class': 'resultsummary-table-wrapper'}).find_all('tr') + for row in rows: + cells = row.find_all('td') + if len(cells) < 5: + continue + + elem = cells[4].find('a') + + try: + slug = elem.attrs['href'].rstrip('/').split('/')[-1] + live = 'live' in elem.text.lower() + except: + slug = None + live = False + + stream = { + 'label': cells[0].get_text(), + 'date': cells[1].get_text(), + 'start': cells[2].get_text(), + 'end': cells[3].get_text(), + 'slug': slug, + 'live': live, + } + + race['streams'].append(stream) + + return race + + def login(self, username, password): + self.logout() + + s = Session() + s.headers.update(HEADERS) + + if not password: + raise APIError(_.LOGIN_ERROR) + + r = s.get(BASE_URL+'superview/', timeout=20) + soup = BeautifulSoup(r.text, 'html.parser') + + login_form = soup.find(id="membersignin") + inputs = login_form.find_all('input') + + data = {} + for elem in inputs: + if elem.attrs.get('value'): + data[elem.attrs['name']] = elem.attrs['value'] + + data.update({ + 'signinusername': username, + 'signinpassword': password, + }) + + r = s.post(BASE_URL+'superview/', data=data, allow_redirects=False, timeout=20) + if r.status_code != 302: + raise APIError(_.LOGIN_ERROR) + + if settings.getBool('save_password', False): + userdata.set(PASSWORD_KEY, password) + + for cookie in r.cookies: + if cookie.name.startswith('wordpress_logged_in'): + userdata.set('_cookies', {cookie.name: cookie.value}) + break + + def logout(self): + userdata.delete(PASSWORD_KEY) + userdata.delete('expires') #legacy + userdata.delete('_cookies') + + def play(self, slug): + r = self._get('superviews/{}/'.format(slug)) + + soup = BeautifulSoup(r.text, 'html.parser') + + bc_div = soup.find("div", {"class": "BrightcoveExperience"}) + bc_data = bc_div.find('video') + + bc_accont = bc_data.attrs['data-account'] + referenceID = bc_data.attrs['data-video-id'] + + return self.get_brightcove_src(bc_accont, referenceID) + + def get_brightcove_src(self, bc_accont, referenceID): + headers = { + 'User-Agent': 'Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36', + 'Origin': 'https://www.supercars.com', + 'X-Forwarded-For': '18.233.21.73', + 'BCOV-POLICY': BRIGHTCOVE_KEY, + } + + brightcove_url = BRIGHTCOVE_URL.format(bc_accont, referenceID) + data = Session().get(brightcove_url, headers=headers).json() + return util.process_brightcove(data) \ No newline at end of file diff --git a/plugin.video.superview/resources/lib/constants.py b/plugin.video.superview/resources/lib/constants.py new file mode 100644 index 00000000..d86714c0 --- /dev/null +++ b/plugin.video.superview/resources/lib/constants.py @@ -0,0 +1,11 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36', +} + +BASE_URL = 'https://www.supercars.com/' +PASSWORD_KEY = 'QXff' + +BRIGHTCOVE_URL = 'https://edge.api.brightcove.com/playback/v1/accounts/{}/videos/{}' +BRIGHTCOVE_KEY = 'BCpkADawqM0_4J6Oa-Vp-_S7CiP77ylYCzC-dEFbNdY_psjSngN9mdH2QF25U80yc82LceQE7KmbGIUHCQHHeItznokQVdDTQ0Mn-87Sc6nBA3gEHFhE29bHe45NIUkW5j7IPakRZnyh-KFJQ7wAggSr63oL6YSK_IA2jmoEyjCuJ4z329ISfycWnrHgBXzpOXuNkMrLQdOQILq83GYBvJZXl7H1xX77cyelvMbgPEfCu0MndCiGa8KIhLOCxWmg6EgB_zAirE-oPpLEqBXkst7nUNllm8jICVgMUtoX7grfyATatyySmZfL5tzUSqo0R1cfOydFZHckziPyaI_uz_7tlurME8jrgpVl7b5nH8sQI3AmvncSYoF9MQsdAsC8JsWSnYcrjJwt7LEYoHhDcX1ccxtAbUIAz9dU1btaS56YuRVMKCcJFVvo1ymmPYjJkqgYCMVEcm-IQe6fU703SrNnlnrta9egLe8ccZiKzDrucCRk84sf0w8EkUc' + +RACES_CACHE_TIME = (60*5) \ No newline at end of file diff --git a/plugin.video.superview/resources/lib/language.py b/plugin.video.superview/resources/lib/language.py new file mode 100644 index 00000000..a2fa72e1 --- /dev/null +++ b/plugin.video.superview/resources/lib/language.py @@ -0,0 +1,16 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30000 + ASK_PASSWORD = 30001 + RACES = 30002 + RACE_NOT_FOUND = 30003 + NO_STREAMS = 30004 + SESSION_EXPIRED = 30005 + SAVE_PASSWORD = 30006 + SAVE_PASSWORD_RELOGIN = 30007 + NO_RACES = 30008 + NOT_PAID = 30009 + LIVE_LABEL = 30010 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.superview/resources/lib/plugin.py b/plugin.video.superview/resources/lib/plugin.py new file mode 100644 index 00000000..2128d602 --- /dev/null +++ b/plugin.video.superview/resources/lib/plugin.py @@ -0,0 +1,121 @@ +from slyguy import plugin, gui, userdata, signals, settings +from slyguy.exceptions import Error +from slyguy.constants import PLAY_FROM_TYPES, PLAY_FROM_ASK, PLAY_FROM_LIVE, PLAY_FROM_START, ROUTE_LIVE_TAG + +from .api import API +from .language import _ + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not plugin.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.RACES, _bold=True), path=plugin.url_for(races)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), bookmark=False) + + return folder + +@plugin.route() +@plugin.login_required() +def races(**kwargs): + folder = plugin.Folder(_.RACES, no_items_label=_.NO_RACES) + + races = api.races() + + for slug in races: + folder.add_item( + label = races[slug]['title'], + path = plugin.url_for(race, slug=slug) + ) + + return folder + +@plugin.route() +@plugin.login_required() +def race(slug, **kwargs): + races = api.races() + if slug not in races: + raise Error(_.RACE_NOT_FOUND) + + race = races[slug] + folder = plugin.Folder(race['title'], no_items_label=_.NO_STREAMS) + + for stream in race['streams']: + if not stream['slug']: + continue + + item = plugin.Item( + label = stream['label'], + path = plugin.url_for(play, slug=stream['slug']), + playable = True, + ) + + if stream['live']: + item.label = _(_.LIVE_LABEL, title=stream['label']) + + item.context.append((_.PLAY_FROM_LIVE, "PlayMedia({})".format( + plugin.url_for(play, slug=stream['slug'], play_type=PLAY_FROM_LIVE, _is_live=True) + ))) + + item.context.append((_.PLAY_FROM_START, "PlayMedia({})".format( + plugin.url_for(play, slug=stream['slug'], play_type=PLAY_FROM_START, _is_live=True) + ))) + + item.path = plugin.url_for(play, slug=stream['slug'], play_type=settings.getEnum('live_play_type', PLAY_FROM_TYPES, PLAY_FROM_ASK), _is_live=True) + + folder.add_items([item]) + + return folder + +@plugin.route() +@plugin.login_required() +def play(slug, play_type=PLAY_FROM_LIVE, **kwargs): + item = api.play(slug) + + if ROUTE_LIVE_TAG in kwargs and item.inputstream: + item.inputstream.live = True + + play_type = int(play_type) + if play_type == PLAY_FROM_LIVE or (play_type == PLAY_FROM_ASK and not gui.yes_no(_.PLAY_FROM, yeslabel=_.PLAY_FROM_LIVE, nolabel=_.PLAY_FROM_START)): + item.properties['ResumeTime'] = 1 + item.properties['TotalTime'] = 1 + + return item + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() \ No newline at end of file diff --git a/plugin.video.superview/resources/settings.xml b/plugin.video.superview/resources/settings.xml new file mode 100644 index 00000000..c59fd741 --- /dev/null +++ b/plugin.video.superview/resources/settings.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.tab.nz/.iptv_merge b/plugin.video.tab.nz/.iptv_merge new file mode 100644 index 00000000..4366827b --- /dev/null +++ b/plugin.video.tab.nz/.iptv_merge @@ -0,0 +1,5 @@ +{ + "version": 3, + "playlist": "plugin://$ID/?_=playlist&output=$FILE", + "epg": "https://i.mjh.nz/nz/epg.xml.gz" +} \ No newline at end of file diff --git a/plugin.video.tab.nz/__init__.py b/plugin.video.tab.nz/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.tab.nz/addon.xml b/plugin.video.tab.nz/addon.xml new file mode 100644 index 00000000..aca31759 --- /dev/null +++ b/plugin.video.tab.nz/addon.xml @@ -0,0 +1,23 @@ + + + + + + + video + + + Watch Trackside and more! + +Requires a valid TAB New Zealand login with a positive balance. + true + + + + Add Bookmarks + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.tab.nz/default.py b/plugin.video.tab.nz/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.tab.nz/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.tab.nz/fanart.jpg b/plugin.video.tab.nz/fanart.jpg new file mode 100644 index 00000000..257d3097 Binary files /dev/null and b/plugin.video.tab.nz/fanart.jpg differ diff --git a/plugin.video.tab.nz/icon.png b/plugin.video.tab.nz/icon.png new file mode 100644 index 00000000..27d337ac Binary files /dev/null and b/plugin.video.tab.nz/icon.png differ diff --git a/plugin.video.tab.nz/resources/__init__.py b/plugin.video.tab.nz/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.tab.nz/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.tab.nz/resources/language/resource.language.en_gb/strings.po b/plugin.video.tab.nz/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..f76f2b6d --- /dev/null +++ b/plugin.video.tab.nz/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,70 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Trackside 1" +msgstr "" + +msgctxt "#30002" +msgid "Trackside 2" +msgstr "" + +msgctxt "#30003" +msgid "Live Events" +msgstr "" + +msgctxt "#30004" +msgid "Username" +msgstr "" + +msgctxt "#30005" +msgid "Password" +msgstr "" + +msgctxt "#30006" +msgid "No Events" +msgstr "" + +msgctxt "#30007" +msgid "Trackside Radio" +msgstr "" + +msgctxt "#30008" +msgid "Authorisation Error\n" +"Try logout and login again" +msgstr "" + +msgctxt "#30009" +msgid "Region error.\n" +"You must have a New Zealand IP address to use this plugin." +msgstr "" + +msgctxt "#30010" +msgid "Save Password" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.tab.nz/resources/lib/__init__.py b/plugin.video.tab.nz/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.tab.nz/resources/lib/api.py b/plugin.video.tab.nz/resources/lib/api.py new file mode 100644 index 00000000..c32476b2 --- /dev/null +++ b/plugin.video.tab.nz/resources/lib/api.py @@ -0,0 +1,105 @@ +from slyguy import userdata, settings +from slyguy.session import Session +from slyguy.exceptions import Error + +from .constants import HEADERS +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + + self._session = Session(HEADERS) + self.set_authentication() + + def set_authentication(self): + ob_session = userdata.get('ob_session') + if not ob_session: + return + + self._session.headers.update({'X-OB-Channel': 'I', 'X-OB-SESSION': ob_session}) + + self._session.cookies.clear() + self._session.cookies.update({'OB-SESSION': ob_session, 'OB-PERSIST': '1'}) + + self.logged_in = True + + def login(self, username, password): + self.logout() + + data = { + "username": username, + "password": password + } + + r = self._session.post('https://auth.tab.co.nz/identity-service/api/v1/assertion/by-credentials', json=data) + + if r.status_code == 403: + raise APIError(_.GEO_ERROR) + elif r.status_code != 201: + raise APIError(_.LOGIN_ERROR) + + userdata.set('ob_session', self._session.cookies['OB-SESSION']) + + if settings.getBool('save_password', False): + userdata.set('pswd', password) + else: + userdata.set('ob_tgt', self._session.cookies['OB-TGT']) + + self.set_authentication() + + return r.json()['data']['ticket'] + + def _set_ob_token(self): + password = userdata.get('pswd') + + if password: + ticket = self.login(userdata.get('username'), password) + else: + resp = self._session.post('https://auth.tab.co.nz/identity-service/api/v1/assertion/by-token', cookies={'OB-TGT': userdata.get('ob_tgt')}) + + if resp.status_code == 403: + raise APIError(_.GEO_ERROR) + elif resp.status_code != 201: + raise APIError(_.AUTH_ERROR) + else: + ticket = resp.json()['data']['ticket'] + + resp = self._session.get('https://api.tab.co.nz/account-service/api/v1/account/header', headers={'Authentication': ticket}) + + if 'OB-TOKEN' not in self._session.cookies: + raise APIError(_.AUTH_ERROR) + + userdata.set('ob_session', self._session.cookies['OB-SESSION']) + + def access(self, type, id): + self._set_ob_token() + + url = 'https://api.tab.co.nz/sports-service/api/v1/streams/access/{}/{}'.format(type, id) + r = self._session.post(url) + + if r.status_code == 403: + raise APIError(_.GEO_ERROR) + + data = r.json() + + if data['errors']: + raise APIError(data['errors'][0]['text']) + + return data['data'][0]['streams'][0]['accessInfo']['contentUrl'] + + def live_events(self): + r = self._session.get('https://content.tab.co.nz/content-service/api/v1/q/event-list?liveNow=true&hasLiveStream=true') + + if r.status_code == 403: + raise APIError(_.GEO_ERROR) + + return r.json()['data']['events'] + + def logout(self): + userdata.delete('ob_session') + userdata.delete('ob_tgt') + self.new_session() \ No newline at end of file diff --git a/plugin.video.tab.nz/resources/lib/constants.py b/plugin.video.tab.nz/resources/lib/constants.py new file mode 100644 index 00000000..dc8815b4 --- /dev/null +++ b/plugin.video.tab.nz/resources/lib/constants.py @@ -0,0 +1,5 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36', + 'Origin': 'https://www.tab.co.nz', + 'Referer': 'https://www.tab.co.nz/', +} \ No newline at end of file diff --git a/plugin.video.tab.nz/resources/lib/language.py b/plugin.video.tab.nz/resources/lib/language.py new file mode 100644 index 00000000..16f0e73d --- /dev/null +++ b/plugin.video.tab.nz/resources/lib/language.py @@ -0,0 +1,15 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + TRACKSIDE_1 = 30001 + TRACKSIDE_2 = 30002 + LIVE_EVENTS = 30003 + ASK_USERNAME = 30004 + ASK_PASSWORD = 30005 + NO_EVENTS = 30006 + TRACKSIDE_RADIO = 30007 + AUTH_ERROR = 30008 + GEO_ERROR = 30009 + SAVE_PASSWORD = 30010 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.tab.nz/resources/lib/plugin.py b/plugin.video.tab.nz/resources/lib/plugin.py new file mode 100644 index 00000000..6358a38f --- /dev/null +++ b/plugin.video.tab.nz/resources/lib/plugin.py @@ -0,0 +1,119 @@ +import os +import codecs + +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.constants import ADDON_PATH +from slyguy.log import log + +from .api import API, APIError +from .language import _ +from .constants import HEADERS + +api = API() + +GUEST_SLUGS = { + 'TS1': 'tv.trackside1.m3u8', + 'TS2': 'tv.trackside2.m3u8', + 'TSR': 'radio.or.28.m3u8', +} + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + folder.add_item(label=_(_.TRACKSIDE_1, _bold=True), path=plugin.url_for(play, type='channel', id='TS1', _is_live=True), playable=True) + folder.add_item(label=_(_.TRACKSIDE_2, _bold=True), path=plugin.url_for(play, type='channel', id='TS2', _is_live=True), playable=True) + folder.add_item(label=_(_.TRACKSIDE_RADIO, _bold=True), path=plugin.url_for(play, type='channel', id='TSR', _is_live=True), playable=True) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE_EVENTS, _bold=True), path=plugin.url_for(live_events)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +def play(type, id, **kwargs): + if type == 'channel' and (id == 'TSR' or not api.logged_in): + url = 'https://i.mjh.nz/nz/{}'.format(GUEST_SLUGS[id]) + else: + url = api.access(type, id) + + item = plugin.Item( + path = url, + inputstream = inputstream.HLS(live=True, force=True) if id != 'TSR' else None, + headers = HEADERS, + ) + + return item + +@plugin.route() +def live_events(**kwargs): + folder = plugin.Folder(_.LIVE_EVENTS) + + events = api.live_events() + for event in events: + folder.add_item( + label = event['name'], + path = plugin.url_for(play, type='event', id=event['id'], _is_live=True), + playable = True, + ) + + if not folder.items: + folder.add_item(label=_(_.NO_EVENTS, _label=True), is_folder=False) + + return folder + +@plugin.route() +@plugin.merge() +def playlist(output, **kwargs): + playlist = u'''#EXTM3U +#EXTINF:-1 tvg-id="tv.trackside1" tvg-logo="https://i.mjh.nz/.images/tv.trackside1.png",TAB Trackside 1 +{TS1_PATH} +#EXTINF:-1 tvg-id="tv.trackside2" tvg-logo="https://i.mjh.nz/.images/tv.trackside2.png",TAB Trackside 2 +{TS2_PATH} +#EXTINF:-1 tvg-id="radio.or.28" tvg-logo="https://i.mjh.nz/.images/radio.or.28.png" radio="true",TAB Trackside Radio +{TSRADIO_PATH} +'''.format( + TS1_PATH = plugin.url_for(play, type='channel', id='TS1', _is_live=True), + TS2_PATH = plugin.url_for(play, type='channel', id='TS2', _is_live=True), + TSRADIO_PATH = plugin.url_for(play, type='channel', id='TSR', _is_live=True), + ) + + with codecs.open(output, 'w', encoding='utf8') as f: + f.write(playlist) \ No newline at end of file diff --git a/plugin.video.tab.nz/resources/settings.xml b/plugin.video.tab.nz/resources/settings.xml new file mode 100644 index 00000000..9ad76bcc --- /dev/null +++ b/plugin.video.tab.nz/resources/settings.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.tester/__init__.py b/plugin.video.tester/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.tester/addon.xml b/plugin.video.tester/addon.xml new file mode 100644 index 00000000..ba78f789 --- /dev/null +++ b/plugin.video.tester/addon.xml @@ -0,0 +1,21 @@ + + + + + + + video + + + + true + + + + + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.tester/default.py b/plugin.video.tester/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.tester/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.tester/icon.png b/plugin.video.tester/icon.png new file mode 100644 index 00000000..28b04ded Binary files /dev/null and b/plugin.video.tester/icon.png differ diff --git a/plugin.video.tester/resources/__init__.py b/plugin.video.tester/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.tester/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.tester/resources/language/resource.language.en_gb/strings.po b/plugin.video.tester/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..e8d0849d --- /dev/null +++ b/plugin.video.tester/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,108 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "My Library" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32034" +msgid "General" +msgstr "" + +msgctxt "#32035" +msgid "Playback" +msgstr "" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "" + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "" + +msgctxt "#32023" +msgid "Use Inputstream HLS" +msgstr "" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "" + +msgctxt "#32036" +msgid "Advanced" +msgstr "" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "" + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" diff --git a/plugin.video.tester/resources/lib/__init__.py b/plugin.video.tester/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.tester/resources/lib/constants.py b/plugin.video.tester/resources/lib/constants.py new file mode 100644 index 00000000..5662ce85 --- /dev/null +++ b/plugin.video.tester/resources/lib/constants.py @@ -0,0 +1,23 @@ +VIDEO_TESTS = [ + { + 'name': 'HLS', + 'type': 'std', + 'url': 'https://bitmovin-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8', + }, + { + 'name': 'InputStream Adaptive - HLS', + 'type': 'ia_hls', + 'url': 'https://bitmovin-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8', + }, + { + 'name': 'InputStream Adaptive - Dash', + 'type': 'ia_mpd', + 'url': 'https://bitmovin-a.akamaihd.net/content/MI201109210084_1/mpds/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.mpd', + }, + { + 'name': 'InputStream Adaptive - Dash with Widevine', + 'type': 'ia_widevine', + 'url': 'https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/mpds/11331.mpd', + 'license_key': 'https://widevine-proxy.appspot.com/proxy', + }, +] \ No newline at end of file diff --git a/plugin.video.tester/resources/lib/language.py b/plugin.video.tester/resources/lib/language.py new file mode 100644 index 00000000..ae3397ac --- /dev/null +++ b/plugin.video.tester/resources/lib/language.py @@ -0,0 +1,6 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + pass + +_ = Language() \ No newline at end of file diff --git a/plugin.video.tester/resources/lib/plugin.py b/plugin.video.tester/resources/lib/plugin.py new file mode 100644 index 00000000..8e8feff0 --- /dev/null +++ b/plugin.video.tester/resources/lib/plugin.py @@ -0,0 +1,36 @@ +from slyguy import plugin, inputstream +from slyguy.language import _ + +from .constants import VIDEO_TESTS + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + for index, video in enumerate(VIDEO_TESTS): + folder.add_item( + label = video['name'], + path = plugin.url_for(play_video, index=index), + playable = True, + ) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS)) + + return folder + +@plugin.route() +def play_video(index, **kwargs): + video = VIDEO_TESTS[int(index)] + + item = plugin.Item( + path = video['url'], + ) + + if video['type'] == 'ia_hls': + item.inputstream = inputstream.HLS(force=True, live=False) + elif video['type'] == 'ia_mpd': + item.inputstream = inputstream.MPD() + elif video['type'] == 'ia_widevine': + item.inputstream = inputstream.Widevine(video.get('license_key')) + + return item \ No newline at end of file diff --git a/plugin.video.tester/resources/settings.xml b/plugin.video.tester/resources/settings.xml new file mode 100644 index 00000000..6070a347 --- /dev/null +++ b/plugin.video.tester/resources/settings.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.three.now/__init__.py b/plugin.video.three.now/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.three.now/addon.xml b/plugin.video.three.now/addon.xml new file mode 100644 index 00000000..6944b9c7 --- /dev/null +++ b/plugin.video.three.now/addon.xml @@ -0,0 +1,21 @@ + + + + + + + video + + + Watch the latest TV On Demand episodes of all your favourite shows from Three with ThreeNow + true + + + + Re-arrange Shows + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.three.now/default.py b/plugin.video.three.now/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.three.now/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.three.now/fanart.jpg b/plugin.video.three.now/fanart.jpg new file mode 100644 index 00000000..2e8591f4 Binary files /dev/null and b/plugin.video.three.now/fanart.jpg differ diff --git a/plugin.video.three.now/icon.png b/plugin.video.three.now/icon.png new file mode 100644 index 00000000..681b1053 Binary files /dev/null and b/plugin.video.three.now/icon.png differ diff --git a/plugin.video.three.now/resources/__init__.py b/plugin.video.three.now/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.three.now/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.three.now/resources/language/resource.language.en_gb/strings.po b/plugin.video.three.now/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..6f6e43c0 --- /dev/null +++ b/plugin.video.three.now/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,52 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Shows" +msgstr "" + +msgctxt "#30001" +msgid "Categories" +msgstr "" + +msgctxt "#30002" +msgid "Live TV" +msgstr "" + +msgctxt "#30003" +msgid "ALL" +msgstr "" + +msgctxt "#30004" +msgid "Shows: {sort}" +msgstr "" + +msgctxt "#30005" +msgid "0 - 9" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.three.now/resources/lib/__init__.py b/plugin.video.three.now/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.three.now/resources/lib/api.py b/plugin.video.three.now/resources/lib/api.py new file mode 100644 index 00000000..a42e011a --- /dev/null +++ b/plugin.video.three.now/resources/lib/api.py @@ -0,0 +1,57 @@ +from difflib import SequenceMatcher + +from slyguy.session import Session +from slyguy import util, mem_cache + +from .constants import HEADERS, API_URL, BRIGHTCOVE_URL, BRIGHTCOVE_KEY, BRIGHTCOVE_ACCOUNT, SEARCH_MATCH_RATIO + +class API(object): + def __init__(self): + self._session = Session(HEADERS, base_url=API_URL) + + @mem_cache.cached(60*10) + def _shows(self): + return self._session.get('shows').json() + + def shows(self): + return self._shows()['shows'] + + def show(self, id): + return self._session.get('shows/{}'.format(id)).json()['show'] + + def channels(self): + return self._shows()['channels'] + + def live(self): + return self._session.get('live-epg').json()['channels'] + + def genres(self): + genres = self.channels() + genres.extend(self._shows()['genres']) + return genres + + def genre(self, genre): + shows = [] + + for show in self.shows(): + if genre in show['genres'] or genre == show['channel']: + shows.append(show) + + return shows + + def search(self, query): + shows = [] + + for show in self.shows(): + if query.lower() in show['name'].lower() or SequenceMatcher(None, query.lower(), show['name'].lower()).ratio() >= SEARCH_MATCH_RATIO: + shows.append(show) + + return shows + + def get_brightcove_src(self, referenceID): + brightcove_url = BRIGHTCOVE_URL.format(BRIGHTCOVE_ACCOUNT, referenceID) + + resp = self._session.get(brightcove_url, headers={'BCOV-POLICY': BRIGHTCOVE_KEY}) + data = resp.json() + + return util.process_brightcove(data) \ No newline at end of file diff --git a/plugin.video.three.now/resources/lib/constants.py b/plugin.video.three.now/resources/lib/constants.py new file mode 100644 index 00000000..39a1c175 --- /dev/null +++ b/plugin.video.three.now/resources/lib/constants.py @@ -0,0 +1,18 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36', + 'X-Forwarded-For': '202.89.4.222', +} + +API_URL = 'https://now-api4-prod.fullscreen.nz/v4/{}' + +SHOWS_EXPIRY = (60*60*24) #24 Hours +LIVE_EXPIRY = (60*60*24) #24 Hours +EPISODE_EXPIRY = (60*5) #5 Minutes +SHOWS_CACHE_KEY = 'shows' +LIVE_CACHE_KEY = 'live' + +SEARCH_MATCH_RATIO = 0.75 + +BRIGHTCOVE_URL = 'https://edge.api.brightcove.com/playback/v1/accounts/{}/videos/{}' +BRIGHTCOVE_KEY = 'BCpkADawqM2NDYVFYXV66rIDrq6i9YpFSTom-hlJ_pdoGkeWuItRDsn1Bhm7QVyQvFIF0OExqoywBvX5-aAFaxYHPlq9st-1mQ73ZONxFHTx0N7opvkHJYpbd_Hi1gJuPP5qCFxyxB8oevg-' +BRIGHTCOVE_ACCOUNT = '3812193411001' \ No newline at end of file diff --git a/plugin.video.three.now/resources/lib/language.py b/plugin.video.three.now/resources/lib/language.py new file mode 100644 index 00000000..0cace644 --- /dev/null +++ b/plugin.video.three.now/resources/lib/language.py @@ -0,0 +1,11 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + SHOWS = 30000 + GENRE = 30001 + LIVE = 30002 + ALL = 30003 + SHOWS_LETTER = 30004 + ZERO_NINE = 30005 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.three.now/resources/lib/plugin.py b/plugin.video.three.now/resources/lib/plugin.py new file mode 100644 index 00000000..087ef5e1 --- /dev/null +++ b/plugin.video.three.now/resources/lib/plugin.py @@ -0,0 +1,199 @@ +import string + +from kodi_six import xbmcplugin + +from slyguy import plugin, gui, settings, userdata, inputstream + +from .api import API +from .constants import EPISODE_EXPIRY +from .language import _ + +api = API() + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + folder.add_item(label=_(_.SHOWS, _bold=True), path=plugin.url_for(shows)) + folder.add_item(label=_(_.GENRE, _bold=True), path=plugin.url_for(genres)) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + folder.add_item(label=_(_.LIVE, _bold=True), path=plugin.url_for(live)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def shows(sort=None, **kwargs): + SORT_ALL = 'ALL' + SORT_0_9 = '0 - 9' + + sortings = [[_(_.ALL, _bold=True), SORT_ALL], [_.ZERO_NINE, SORT_0_9]] + for letter in string.ascii_uppercase: + sortings.append([letter, letter]) + + if sort is None: + folder = plugin.Folder(_.SHOWS) + + for sorting in sortings: + folder.add_item(label=sorting[0], path=plugin.url_for(shows, sort=sorting[1])) + + return folder + + if sort == SORT_ALL: + label = _.ALL + elif sort == SORT_0_9: + label = _.ZERO_NINE + else: + label = sort + + folder = plugin.Folder(_(_.SHOWS_LETTER, sort=label)) + + rows = [] + for row in api.shows(): + if not row['name'].strip(): + continue + + sort_by = row['name'].upper().strip()[0] + if sort_by not in string.ascii_uppercase: + sort_by = SORT_0_9 + + if sort == SORT_ALL or sort == sort_by: + rows.append(row) + + items = _parse_shows(rows) + folder.add_items(items) + + return folder + +@plugin.route() +def genres(**kwargs): + folder = plugin.Folder(_.GENRE) + + for row in api.genres(): + folder.add_item( + label = row['displayName'], + path = plugin.url_for(genre, genre=row['slug'], title=row['displayName']), + art = {'thumb': row.get('logo', None)}, + ) + + return folder + +@plugin.route() +def genre(genre, title, **kwargs): + folder = plugin.Folder(title) + items = _parse_shows(api.genre(genre)) + folder.add_items(items) + return folder + +def _parse_shows(rows): + items = [] + for row in rows: + thumb = row.get('images',{}).get('showTile','').replace('[width]', '301').replace('[height]', '227') + fanart = row.get('images',{}).get('dashboardHero','').replace('[width]', '1600').replace('[height]', '520') + + item = plugin.Item( + label = row['name'], + art = {'thumb': thumb, 'fanart': fanart}, + path = plugin.url_for(show, id=row['showId']), + info = { + 'title': row['name'], + 'plot': row.get('synopsis'), + # 'mediatype': 'tvshow', + 'tvshowtitle': row['name'], + } + ) + + items.append(item) + + return items + +def _parse_episodes(rows): + items = [] + for row in rows: + videoid = row['videoRenditions']['videoCloud']['brightcoveId'] + thumb = row.get('images',{}).get('videoTile','').split('?')[0] + + info = { + 'title': row['name'], + 'genre': row.get('genre'), + 'plot': row.get('synopsis'), + 'duration': int(row.get('duration')), + 'aired': row.get('airedDate'), + 'dateadded': row.get('airedDate'), + 'mediatype': 'episode', + 'tvshowtitle': row.get('showTitle'), + } + + try: + info.update({ + 'episode': int(row.get('episode')), + 'season': int(row.get('season')), + }) + except: + pass + + item = plugin.Item( + label = row['name'], + art = {'thumb': thumb}, + path = plugin.url_for(play, id=videoid), + info = info, + playable = True, + ) + + items.append(item) + + return items + +@plugin.route() +def show(id, **kwargs): + row = api.show(id) + fanart = row.get('images',{}).get('dashboardHero','').replace('[width]', '1600').replace('[height]', '520') + folder = plugin.Folder(row['name'], sort_methods=[xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED, xbmcplugin.SORT_METHOD_UNSORTED], fanart=fanart) + folder.add_items(_parse_episodes(row['episodes'])) + return folder + +@plugin.route() +def play(id, **kwargs): + return api.get_brightcove_src(id) + +@plugin.route() +def play_channel(channel, **kwargs): + for row in api.live(): + if row['title'] == channel: + return plugin.Item( + inputstream = inputstream.HLS(live=True), + path = row['videoRenditions']['videoCloud']['hlsUrl'], + art = False, + ) + +@plugin.route() +def search(**kwargs): + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + items = _parse_shows(api.search(query)) + folder.add_items(items) + + return folder + +@plugin.route() +def live(**kwargs): + folder = plugin.Folder(_.LIVE) + + for row in api.live(): + folder.add_item( + label = row['displayName'], + art = {'thumb': row.get('logo','').split('?')[0]}, + path = plugin.url_for(play_channel, channel=row['title'], _is_live=True), + playable = True, + ) + + return folder \ No newline at end of file diff --git a/plugin.video.three.now/resources/settings.xml b/plugin.video.three.now/resources/settings.xml new file mode 100644 index 00000000..9288c919 --- /dev/null +++ b/plugin.video.three.now/resources/settings.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.tvnz.ondemand/__init__.py b/plugin.video.tvnz.ondemand/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.tvnz.ondemand/addon.xml b/plugin.video.tvnz.ondemand/addon.xml new file mode 100644 index 00000000..620088a3 --- /dev/null +++ b/plugin.video.tvnz.ondemand/addon.xml @@ -0,0 +1,21 @@ + + + + + + + video + + + Watch your favourite shows from TVNZ OnDemand + true + + + + Re-arrange Shows menu + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.tvnz.ondemand/default.py b/plugin.video.tvnz.ondemand/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.tvnz.ondemand/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.tvnz.ondemand/fanart.jpg b/plugin.video.tvnz.ondemand/fanart.jpg new file mode 100644 index 00000000..037a25ef Binary files /dev/null and b/plugin.video.tvnz.ondemand/fanart.jpg differ diff --git a/plugin.video.tvnz.ondemand/icon.png b/plugin.video.tvnz.ondemand/icon.png new file mode 100644 index 00000000..0d38fd1a Binary files /dev/null and b/plugin.video.tvnz.ondemand/icon.png differ diff --git a/plugin.video.tvnz.ondemand/resources/__init__.py b/plugin.video.tvnz.ondemand/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.tvnz.ondemand/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.tvnz.ondemand/resources/language/resource.language.en_gb/strings.po b/plugin.video.tvnz.ondemand/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..4c2ec74b --- /dev/null +++ b/plugin.video.tvnz.ondemand/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,76 @@ +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Shows" +msgstr "" + +msgctxt "#30001" +msgid "ALL" +msgstr "" + +msgctxt "#30002" +msgid "Categories" +msgstr "" + +msgctxt "#30003" +msgid "Live TV" +msgstr "" + +msgctxt "#30004" +msgid "All Episodes" +msgstr "" + +msgctxt "#30005" +msgid "Watch from Start" +msgstr "" + +msgctxt "#30006" +msgid "Next Page" +msgstr "" + +msgctxt "#30007" +msgid "Live Now" +msgstr "" + +msgctxt "#30008" +msgid "Starting Soon" +msgstr "" + +msgctxt "#30009" +msgid "{label} [B]({badge})[/B]" +msgstr "" + +msgctxt "#30010" +msgid "Shows: {sort}" +msgstr "" + +msgctxt "#30011" +msgid "0 - 9" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.tvnz.ondemand/resources/lib/__init__.py b/plugin.video.tvnz.ondemand/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.tvnz.ondemand/resources/lib/api.py b/plugin.video.tvnz.ondemand/resources/lib/api.py new file mode 100644 index 00000000..c70dfd63 --- /dev/null +++ b/plugin.video.tvnz.ondemand/resources/lib/api.py @@ -0,0 +1,101 @@ +from slyguy import util, mem_cache +from slyguy.session import Session + +from .constants import HEADERS, API_URL, BRIGHTCOVE_URL, BRIGHTCOVE_KEY, BRIGHTCOVE_ACCOUNT + +class API(object): + def __init__(self): + self._session = Session(HEADERS, base_url=API_URL) + #x-tvnz-active-profile-id (profile support) + + @mem_cache.cached(60*30) + def _category(self, slug): + return self._session.get('/api/v2/android/play/page/categories/{}'.format(slug)).json() + + def category(self, slug): + shows = [] + + data = self._category(slug) + + for module in data['layout']['slots']['main']['modules']: + items = module.get('items', []) + + for section in module.get('sections', []): + items.extend(section.get('items', [])) + + for item in items: + item['_embedded'] = data['_embedded'][item['href']] + shows.append(item) + + return data['title'], shows + + def a_to_z(self): + data = self._category('all') + + for section in data['layout']['slots']['main']['modules'][0]['sections']: + for row in section['items']: + row['_embedded'] = data['_embedded'][row['href']] + + return data['layout']['slots']['main']['modules'][0]['sections'] + + def categories(self): + data = self._category('all') + + for row in data['layout']['slots']['below']['modules'][0]['items']: + row['_embedded'] = data['_embedded'][row['href']] + + return data['layout']['slots']['below']['modules'][0]['items'] + + def show(self, slug): + data = self._session.get('/api/v2/android/play/page/shows/{}'.format(slug)).json() + + show = data['_embedded'][data['layout']['showHref']] + sections = data['layout']['slots']['main']['modules'][0].get('sections', []) + + for section in sections: + section['_embedded'] = data['_embedded'][section['href']] + + return show, sections, data['_embedded'] + + def video_list(self, href): + data = self._session.get(href).json() + + for row in data['content']: + row['_embedded'] = data['_embedded'][row['href']] + + return data['content'], data['nextPage'] + + def similar(self, href): + data = self._session.get(href).json() + + for row in data['layout']['slots']['main']['modules'][0]['items']: + row['_embedded'] = data['_embedded'][row['href']] + + return data['layout']['slots']['main']['modules'][0]['items'] + + def search(self, query): + params = { + 'q': query.strip(), + 'includeTypes': 'all', + } + + return self._session.get('/api/v1/android/play/search', params=params).json()['results'] + + def channels(self): + data = self._session.get('/api/v2/android/play/page/livetv/').json() + + for row in data['layout']['slots']['hero']['modules'][1]['items']: + row['_embedded'] = data['_embedded'][row['href']] + + return data['layout']['slots']['hero']['modules'][1]['items'] + + def channel(self, slug): + return self._session.get('/api/v1/android/play/channels/{}'.format(slug)).json() + + def get_brightcove_src(self, referenceID): + brightcove_url = BRIGHTCOVE_URL.format(BRIGHTCOVE_ACCOUNT, referenceID) + + resp = self._session.get(brightcove_url, headers={'BCOV-POLICY': BRIGHTCOVE_KEY}) + data = resp.json() + + return util.process_brightcove(data) \ No newline at end of file diff --git a/plugin.video.tvnz.ondemand/resources/lib/constants.py b/plugin.video.tvnz.ondemand/resources/lib/constants.py new file mode 100644 index 00000000..64d678f9 --- /dev/null +++ b/plugin.video.tvnz.ondemand/resources/lib/constants.py @@ -0,0 +1,13 @@ +HEADERS = { + 'User-Agent': 'Android/Ondemand/609', + 'x-tvnz-api-client-id': 'androidphone/2.64.3.609', + 'X-Forwarded-For' : '202.89.4.222', +} + +API_URL = 'https://apis-public-prod.tech.tvnz.co.nz{}' +IMG_BASE = 'https://api.tvnz.co.nz{}' +PAGE_LIMIT = 4 + +BRIGHTCOVE_URL = 'https://edge.api.brightcove.com/playback/v1/accounts/{}/videos/{}' +BRIGHTCOVE_KEY = 'BCpkADawqM0IurzupiJKMb49WkxM__ngDMJ3GOQBhN2ri2Ci_lHwDWIpf4sLFc8bANMc-AVGfGR8GJNgxGqXsbjP1gHsK2Fpkoj6BSpwjrKBnv1D5l5iGPvVYCo' +BRIGHTCOVE_ACCOUNT = '963482467001' \ No newline at end of file diff --git a/plugin.video.tvnz.ondemand/resources/lib/language.py b/plugin.video.tvnz.ondemand/resources/lib/language.py new file mode 100644 index 00000000..7c0c99aa --- /dev/null +++ b/plugin.video.tvnz.ondemand/resources/lib/language.py @@ -0,0 +1,17 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + SHOWS = 30000 + ALL = 30001 + CATEGORIES = 30002 + LIVE_TV = 30003 + ALL_EPISODES = 30004 + WATCH_FROM_START = 30005 + NEXT_PAGE = 30006 + LIVE_NOW = 30007 + STARTING_SOON = 30008 + BADGE = 30009 + SHOWS_LETTER = 30010 + ZERO_NINE = 30011 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.tvnz.ondemand/resources/lib/plugin.py b/plugin.video.tvnz.ondemand/resources/lib/plugin.py new file mode 100644 index 00000000..571700d2 --- /dev/null +++ b/plugin.video.tvnz.ondemand/resources/lib/plugin.py @@ -0,0 +1,345 @@ +import os +import string + +import arrow +from kodi_six import xbmcplugin +from slyguy import plugin, gui, settings, userdata, inputstream +from slyguy.constants import ROUTE_LIVE_TAG, ROUTE_LIVE_SUFFIX + +from .api import API +from .constants import HEADERS +from .language import _ + +api = API() + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + folder.add_item(label=_(_.SHOWS, _bold=True), path=plugin.url_for(shows)) + folder.add_item(label=_(_.CATEGORIES, _bold=True), path=plugin.url_for(categories)) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + folder.add_item(label=_(_.LIVE_TV, _bold=True), path=plugin.url_for(live_tv)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +def _process_show(data): + label = data['title'] + if data['badge']: + label = _(_.BADGE, label=label, badge=data['badge']['label']) + + return plugin.Item( + label = label, + info = {'plot': data['synopsis']}, + art = {'thumb': data['tileImage']['src']+'?width=400', 'fanart': data['coverImage']['src']+'?width=1920&height=548'}, + path = plugin.url_for(show, slug=data['page']['href'].split('/')[-1]), + ) + +def _process_duration(duration): + if not duration: + return None + + keys = [['H', 3600], ['M', 60], ['S', 1]] + + seconds = 0 + duration = duration.lstrip('PT') + for key in keys: + if key[0] in duration: + count, duration = duration.split(key[0]) + seconds += float(count) * key[1] + + return int(seconds) + +def _process_video(data, showname, categories=None): + label = '{}'.format(data['labels']['primary']) + categories = categories or [] + + replaces = { + '${video.broadcastDateTime}': lambda: arrow.get(data['broadcastDateTime']).format('dddd D MMM'), + '${video.seasonNumber}' : lambda: data['seasonNumber'], + '${video.episodeNumber}' : lambda: data['episodeNumber'], + '${video.title}' : lambda: data['title'], + } + + for replace in replaces: + if replace in label: + label = label.replace(replace, replaces[replace]()) + + if 'Movies' in categories: + categories.remove('Movies') + _type = 'movie' + else: + _type = 'episode' + + info = {'plot': data['synopsis'], 'mediatype': _type, 'genre': categories, 'duration': _process_duration(data.get('duration'))} + if _type == 'episode': + info['tvshowtitle'] = showname + info['season'] = data['seasonNumber'] + info['episode'] = data['episodeNumber'] + if data['title'] != showname: + label = data['title'] + + path = None + meta = data['publisherMetadata'] + if 'brightcoveVideoId' in meta: + path = plugin.url_for(play, brightcoveId=meta['brightcoveVideoId']) + elif 'liveStreamUrl' in meta: + path = plugin.url_for(play, livestream=meta['liveStreamUrl'], _is_live=meta['state'] != 'dvr') + + if meta['state'] == 'live': + label = _(_.BADGE, label=label, badge=_.LIVE_NOW) + elif meta['state'] == 'prepromotion': + label = _(_.BADGE, label=label, badge=_.STARTING_SOON) + elif meta['state'] == 'dvr': + pass + + return plugin.Item( + label = label, + info = info, + art = {'thumb': data['image']['src']+'?width=400'}, + playable = path != None, + path = path, + ) + +@plugin.route() +def shows(sort=None, **kwargs): + SORT_ALL = 'ALL' + SORT_0_9 = '0 - 9' + + sortings = [[_(_.ALL, _bold=True), SORT_ALL], [_.ZERO_NINE, SORT_0_9]] + for letter in string.ascii_uppercase: + sortings.append([letter, letter]) + + if sort is None: + folder = plugin.Folder(_.SHOWS) + + for sorting in sortings: + folder.add_item(label=sorting[0], path=plugin.url_for(shows, sort=sorting[1])) + + return folder + + if sort == SORT_ALL: + label = _.ALL + elif sort == SORT_0_9: + label = _.ZERO_NINE + else: + label = sort + + folder = plugin.Folder(_(_.SHOWS_LETTER, sort=label)) + + count = 0 + for section in api.a_to_z(): + if sort == None: + folder.add_item( + label = section['name'], + info = {'plot': '{} Shows'.format(len(section['items']))}, + path = plugin.url_for(shows, sort=section['name']), + ) + + elif sort == section['name'] or sort == SORT_ALL: + for row in section['items']: + item = _process_show(row['_embedded']) + folder.add_items(item) + + return folder + +@plugin.route() +def category(slug, title=None, **kwargs): + _title, shows = api.category(slug) + folder = plugin.Folder(title or _title) + + for row in shows: + item = _process_show(row['_embedded']) + folder.add_items(item) + + return folder + +@plugin.route() +def categories(**kwargs): + folder = plugin.Folder(_.CATEGORIES) + + for row in api.categories(): + folder.add_item( + label = row['_embedded']['title'], + info = {'plot': row['_embedded']['synopsis']}, + art = {'thumb': row['_embedded']['tileImage']['src']+'?width=400'}, + path = plugin.url_for(category, slug=row['href'].split('/')[-1]), + ) + + return folder + +@plugin.route() +def show(slug, **kwargs): + _show, sections, embedded = api.show(slug) + + categories = [] + for i in _show['categories']: + categories.append(i['label']) + + fanart = _show['coverImage']['src']+'?width=1920&height=548' + folder = plugin.Folder(_show['title'], fanart=fanart) + + count = 0 + for row in sections: + if row['_embedded']['sectionType'] == 'similarContent': + folder.add_item( + label = row['label'], + art = {'thumb': _show['tileImage']['src']+'?width=400'}, + path = plugin.url_for(similar, href=row['_embedded']['id'], label=_show['title'], fanart=fanart), + ) + else: + for module in row['_embedded']['layout']['slots']['main']['modules']: + if module['type'] != 'showVideoCollection': + continue + + for _list in module['lists']: + count += 1 + if count == 1 and _show['videosAvailable'] == 1: + # Try to flatten + try: + data = embedded[embedded[_list['href']]['content'][0]['href']] + item = _process_video(data, _show['title'], categories=categories) + folder.add_items(item) + continue + except: + pass + + item = plugin.Item( + label = _list['label'] or module['label'], + art = {'thumb': _show['tileImage']['src']+'?width=400'}, + path = plugin.url_for(video_list, href=_list['href'], label=_show['title'], fanart=fanart), + ) + + if 'season' in item.label.lower(): + folder.items.insert(0, item) + else: + folder.items.append(item) + + return folder + +@plugin.route() +def video_list(href, label, fanart, **kwargs): + if 'sortOrder=oldestFirst' in href: + limit = 60 + sort_methods = [xbmcplugin.SORT_METHOD_EPISODE, xbmcplugin.SORT_METHOD_UNSORTED, xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED] + else: + limit = 10 + sort_methods = [xbmcplugin.SORT_METHOD_UNSORTED, xbmcplugin.SORT_METHOD_EPISODE,xbmcplugin.SORT_METHOD_LABEL, xbmcplugin.SORT_METHOD_DATEADDED] + + folder = plugin.Folder(label, fanart=fanart, sort_methods=sort_methods) + + next_page = href + while next_page: + rows, next_page = api.video_list(next_page) + + for row in rows: + item = _process_video(row['_embedded'], label) + folder.add_items(item) + + if len(folder.items) == limit: + break + + if next_page: + folder.add_item( + label = _(_.NEXT_PAGE), + path = plugin.url_for(video_list, href=next_page, label=label, fanart=fanart), + specialsort = 'bottom', + ) + + return folder + +@plugin.route() +def similar(href, label, fanart, **kwargs): + folder = plugin.Folder(label, fanart=fanart) + + for row in api.similar(href): + item = _process_show(row['_embedded']) + folder.add_items(item) + + return folder + +@plugin.route() +def search(**kwargs): + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + + for row in api.search(query): + if row['type'] == 'show': + item = _process_show(row) + elif row['type'] == 'category': + slug = row['page']['href'].split('/')[-1] + if slug == 'shows': + slug = 'all' + + item = plugin.Item( + label = row['title'], + info = {'plot': row['searchDescription'] or row['synopsis']}, + art = {'thumb': row['tileImage']['src']+'?width=400'}, + path = plugin.url_for(category, slug=slug), + ) + elif row['type'] == 'channel': + item = plugin.Item( + label = row['title'], + info = {'plot': row['searchDescription'] or row['synopsis']}, + art = {'thumb': row['tileImage']['src']+'?width=400'}, + path = plugin.url_for(play, channel=row['page']['href'].split('/')[-1], _is_live=True), + playable = True, + ) + else: + continue + + folder.add_items(item) + + if not folder.items: + return gui.ok(_.NO_RESULTS, heading=folder.title) + + return folder + +@plugin.route() +def live_tv(**kwargs): + folder = plugin.Folder(_.LIVE_TV) + + for row in api.channels(): + folder.add_item( + label = row['_embedded']['title'], + info = {'plot': row['_embedded']['synopsis']}, + art = {'thumb': row['_embedded']['tileImage']['src']+'?width=400'}, + playable = True, + path = plugin.url_for(play, channel=row['href'].split('/')[-1], _is_live=True), + ) + + return folder + +@plugin.route() +def play(livestream=None, brightcoveId=None, channel=None, **kwargs): + if brightcoveId: + item = api.get_brightcove_src(brightcoveId) + + elif livestream: + item = plugin.Item(path=livestream, art=False, inputstream=inputstream.HLS(live=True)) + + if kwargs.get(ROUTE_LIVE_TAG) == ROUTE_LIVE_SUFFIX and not gui.yes_no(_.PLAY_FROM, yeslabel=_.PLAY_FROM_LIVE, nolabel=_.PLAY_FROM_START): + item.properties['ResumeTime'] = '1' + item.properties['TotalTime'] = '1' + + item.inputstream = inputstream.HLS(force=True, live=True) + if not item.inputstream.check(): + plugin.exception(_.LIVE_HLS_REQUIRED) + + elif channel: + data = api.channel(channel) + item = plugin.Item(path=data['publisherMetadata']['liveStreamUrl'], art=False, inputstream=inputstream.HLS(live=True)) + + item.headers = HEADERS + + return item \ No newline at end of file diff --git a/plugin.video.tvnz.ondemand/resources/settings.xml b/plugin.video.tvnz.ondemand/resources/settings.xml new file mode 100644 index 00000000..01bdb5fe --- /dev/null +++ b/plugin.video.tvnz.ondemand/resources/settings.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.udemy/__init__.py b/plugin.video.udemy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.udemy/addon.xml b/plugin.video.udemy/addon.xml new file mode 100644 index 00000000..30b26599 --- /dev/null +++ b/plugin.video.udemy/addon.xml @@ -0,0 +1,21 @@ + + + + + + + video + + + Online Courses - Learn Anything, On Your Schedule + true + + + + Add Bookmarks + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.udemy/default.py b/plugin.video.udemy/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.udemy/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.udemy/fanart.jpg b/plugin.video.udemy/fanart.jpg new file mode 100644 index 00000000..9d0e5dec Binary files /dev/null and b/plugin.video.udemy/fanart.jpg differ diff --git a/plugin.video.udemy/icon.png b/plugin.video.udemy/icon.png new file mode 100644 index 00000000..098e601b Binary files /dev/null and b/plugin.video.udemy/icon.png differ diff --git a/plugin.video.udemy/resources/__init__.py b/plugin.video.udemy/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.udemy/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.udemy/resources/language/resource.language.en_gb/strings.po b/plugin.video.udemy/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..01e1097f --- /dev/null +++ b/plugin.video.udemy/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,85 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "My Courses" +msgstr "" + +msgctxt "#30004" +msgid "Udemy Email" +msgstr "" + +msgctxt "#30005" +msgid "Udemy Password" +msgstr "" + +msgctxt "#30006" +msgid "Failed to login.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30008" +msgid "{title}\n\n" +"{num_lectures} Lectures ({length})" +msgstr "" + +msgctxt "#30009" +msgid "Section {section_number}: {section_title}" +msgstr "" + +msgctxt "#30010" +msgid "No stream found" +msgstr "" + + + + +msgctxt "#30018" +msgid "Business Account" +msgstr "" + +msgctxt "#30019" +msgid "Business Host" +msgstr "" + +msgctxt "#30020" +msgid "Playback" +msgstr "" + +msgctxt "#30021" +msgid "General" +msgstr "" + +msgctxt "#30022" +msgid "Utility" +msgstr "" + +msgctxt "#30023" +msgid "Next Page" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.udemy/resources/lib/__init__.py b/plugin.video.udemy/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.udemy/resources/lib/api.py b/plugin.video.udemy/resources/lib/api.py new file mode 100644 index 00000000..83c66406 --- /dev/null +++ b/plugin.video.udemy/resources/lib/api.py @@ -0,0 +1,178 @@ +import hashlib +import hmac +import datetime + +from slyguy import userdata, settings +from slyguy.session import Session +from slyguy.exceptions import Error + +from .constants import HEADERS, API_URL, DEFAULT_HOST, PAGE_SIZE +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self.logged_in = False + + host = settings.get('business_host') if settings.getBool('business_account', False) else DEFAULT_HOST + if host != userdata.get('host', DEFAULT_HOST): + userdata.delete('access_token') + userdata.set('host', host) + + self._session = Session(HEADERS, base_url=API_URL.format(host)) + self._set_authentication() + + def _set_authentication(self): + token = userdata.get('access_token') + if not token: + return + + self._session.headers.update({'Authorization': 'Bearer {}'.format(token)}) + self.logged_in = True + + def my_courses(self, page=1, query=None): + params = { + 'page' : page, + 'page_size' : PAGE_SIZE, + 'ordering' : 'title', + 'fields[course]' : 'id,title,image_480x270,image_750x422,headline,num_published_lectures,content_info,completion_ratio', + } + + if query: + params['search'] = query + + return self._session.get('users/me/subscribed-courses', params=params).json() + + def chapters(self, course_id, page=1): + params = { + 'page' : page, + 'page_size' : PAGE_SIZE, + 'fields[course]' : 'image_480x270', + 'fields[chapter]' : 'description,object_index,title,course', + 'fields[lecture]' : 'id', + 'fields[practice]' : 'id', + 'fields[quiz]' : 'id', + } + + data = self._session.get('courses/{}/cached-subscriber-curriculum-items'.format(course_id), params=params).json() + rows = [r for r in data['results'] if r['_class'] == 'chapter'] + return rows, data['next'] + + def lectures(self, course_id, chapter_id, page=1): + params = { + 'page' : page, + 'page_size' : PAGE_SIZE, + 'fields[course]' : 'image_480x270,title', + 'fields[chapter]' : 'id', + 'fields[lecture]' : 'title,object_index,description,is_published,course,id,asset', + 'fields[asset]' : 'asset_type,length,status', + 'fields[practice]' : 'id', + 'fields[quiz]' : 'id', + } + + data = self._session.get('courses/{}/cached-subscriber-curriculum-items'.format(course_id), params=params).json() + + lectures = [] + found = False + for row in data['results']: + if not found and row['_class'] == 'chapter' and row['id'] == int(chapter_id): + found = True + + elif found and row['_class'] == 'lecture' and row['is_published'] and row['asset']['asset_type'] in ('Video', 'Audio'): + lectures.append(row) + + elif found and row['_class'] == 'chapter': + break + + return lectures, data['next'] + + def get_stream_data(self, asset_id): + params = { + 'fields[asset]' : '@all', + } + + return self._session.get('assets/{0}'.format(asset_id), params=params).json() + + def login(self, username, password): + data = { + 'email': username, + 'password': password, + 'upow': self._get_upow(username, 'login') + } + + params = { + 'fields[user]': 'title,image_100x100,name,access_token', + } + + r = self._session.post('auth/udemy-auth/login/', params=params, data=data) + try: + data = r.json() + except: + raise APIError(_(_.LOGIN_ERROR, msg=r.status_code)) + + access_token = data.get('access_token') + if not access_token: + raise APIError(_(_.LOGIN_ERROR, msg=data.get('detail', ''))) + + userdata.set('access_token', access_token) + self._set_authentication() + + def logout(self): + userdata.delete('access_token') + self.new_session() + + def _get_upow(self, message, secret): + date = datetime.datetime.today().strftime('%Y%m%d') + + def get_token(email, date, secret): + message = email + date + i = 0 + + for x in range(0, 20): + i3 = i * 50 + + while True: + i2 = i + 1 + if i3 >= i2 * 50: + break + + i4 = i3 * 1000 + i3 += 1 + token = hash_calc(i4, i3 * 1000, message, secret) + if token: + return token + + i = i2 + + return None + + def m26785a(i): + f19175e = "" + while i >= 0: + f19175e += chr(((i % 26) + 65)) + i = int(i / 26) - 1 + return f19175e[::-1] + + def hash_calc(i, i2, message, password): + a = m26785a(i) + _bytes = bytearray(message + a, 'utf8') + password = password.encode() + + while i < i2: + _i = i + if (_i % 26 == 0): + _bytes = bytearray(message + m26785a(_i), 'utf8') + else: + _bytes[len(_bytes) - 1] = (_bytes[len(_bytes) - 1] + 1) + + doFinal = hmac.new(password, _bytes, digestmod=hashlib.sha256).hexdigest() + if doFinal[0:2] == '00' and doFinal[2:4] == '00': + return m26785a(i) + + i += 1 + + return None + + return date + get_token(message, date, secret) \ No newline at end of file diff --git a/plugin.video.udemy/resources/lib/constants.py b/plugin.video.udemy/resources/lib/constants.py new file mode 100644 index 00000000..4e57dae9 --- /dev/null +++ b/plugin.video.udemy/resources/lib/constants.py @@ -0,0 +1,16 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Safari/537.36', + 'Authorization': 'Basic YWQxMmVjYTljYmUxN2FmYWM2MjU5ZmU1ZDk4NDcxYTY6YTdjNjMwNjQ2MzA4ODI0YjIzMDFmZGI2MGVjZmQ4YTA5NDdlODJkNQ==', +} + +CLIENT_ID = 'bd2565cb7b0c313f5e9bae44961e8db2' +DEFAULT_HOST = 'www.udemy.com' +API_URL = 'https://{}/api-2.0/{{}}' +PAGE_SIZE = 1400 + +BANDWIDTH_MAP = { + 720: [2500000, '1280x720'], + 480: [1300000, '854x480'], + 360: [660000, '640x360'], + 144: [190000, '256x144'], +} \ No newline at end of file diff --git a/plugin.video.udemy/resources/lib/language.py b/plugin.video.udemy/resources/lib/language.py new file mode 100644 index 00000000..a17e55b6 --- /dev/null +++ b/plugin.video.udemy/resources/lib/language.py @@ -0,0 +1,20 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + MY_COURSES = 30001 + ASK_USERNAME = 30004 + ASK_PASSWORD = 30005 + LOGIN_ERROR = 30006 + COURSE_INFO = 30008 + SECTION_LABEL = 30009 + NO_STREAM_ERROR = 30010 + + + BUSINESS_ACCOUNT = 30018 + BUSINESS_NAME = 30019 + PLAYBACK = 30020 + GENERAL = 30021 + UTILITY = 30022 + NEXT_PAGE = 30023 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.udemy/resources/lib/plugin.py b/plugin.video.udemy/resources/lib/plugin.py new file mode 100644 index 00000000..777487a4 --- /dev/null +++ b/plugin.video.udemy/resources/lib/plugin.py @@ -0,0 +1,292 @@ +import re + +from six.moves.html_parser import HTMLParser + +from slyguy import plugin, gui, settings, userdata, inputstream, signals +from slyguy.log import log +from slyguy.constants import QUALITY_TAG, QUALITY_CUSTOM, QUALITY_ASK, QUALITY_BEST, QUALITY_LOWEST, QUALITY_TYPES +from slyguy.exceptions import FailedPlayback + +from .api import API +from .language import _ +from .constants import CLIENT_ID, BANDWIDTH_MAP + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder(cacheToDisc=False) + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.MY_COURSES, _bold=True), path=plugin.url_for(my_courses)) + folder.add_item(label=_(_.SEARCH, _bold=True), path=plugin.url_for(search)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@plugin.route() +def search(query=None, page=1, **kwargs): + page = int(page) + if not query: + query = gui.input(_.SEARCH, default=userdata.get('search', '')).strip() + if not query: + return + + userdata.set('search', query) + + folder = plugin.Folder(_(_.SEARCH_FOR, query=query)) + data = api.my_courses(page=page, query=query) + items = _process_courses(data['results']) + folder.add_items(items) + + if data['next']: + folder.add_item( + label = _(_.NEXT_PAGE, _bold=True), + path = plugin.url_for(search, query=query, page=page+1), + ) + + return folder + +@plugin.route() +def my_courses(page=1, **kwargs): + page = int(page) + folder = plugin.Folder(_.MY_COURSES) + data = api.my_courses(page=page) + items = _process_courses(data['results']) + folder.add_items(items) + + if data['next']: + folder.add_item( + label = _(_.NEXT_PAGE, _bold=True), + path = plugin.url_for(my_courses, page=page+1), + ) + + return folder + +def _process_courses(rows): + items = [] + for row in rows: + plot = _(_.COURSE_INFO, + title = row['headline'], + num_lectures = row['num_published_lectures'], + percent_complete = row['completion_ratio'], + length = row['content_info'], + ) + + item = plugin.Item( + label = row['title'], + path = plugin.url_for(chapters, course_id=row['id'], title=row['title']), + art = {'thumb': row['image_480x270']}, + info = {'plot': plot}, + is_folder = True, + ) + + items.append(item) + + return items + +@plugin.route() +def chapters(course_id, title, page=1, **kwargs): + page = int(page) + folder = plugin.Folder(title) + + rows, next_page = api.chapters(course_id, page=page) + + for row in sorted(rows, key=lambda r: r['object_index']): + folder.add_item( + label = _(_.SECTION_LABEL, section_number=row['object_index'], section_title=row['title']), + path = plugin.url_for(lectures, course_id=course_id, chapter_id=row['id'], title=title), + art = {'thumb': row['course']['image_480x270']}, + info = {'plot': strip_tags(row['description'])}, + ) + + if next_page: + folder.add_item( + label = _(_.NEXT_PAGE, _bold=True), + path = plugin.url_for(chapters, course_id=course_id, title=title, page=page+1), + ) + + return folder + +@plugin.route() +def lectures(course_id, chapter_id, title, page=1, **kwargs): + page = int(page) + folder = plugin.Folder(title) + + rows, next_page = api.lectures(course_id, chapter_id, page=page) + + for row in rows: + folder.add_item( + label = row['title'], + path = plugin.url_for(play, asset_id=row['asset']['id']), + art = {'thumb': row['course']['image_480x270']}, + info = { + 'title': row['title'], + 'plot': strip_tags(row['description']), + 'duration': row['asset']['length'], + 'mediatype': 'episode', + 'tvshowtitle': row['course']['title'], + }, + playable = True, + ) + + if next_page: + folder.add_item( + label = _(_.NEXT_PAGE, _bold=True), + path = plugin.url_for(lectures, course_id=course_id, chapter_id=chapter_id, title=title, page=page+1), + ) + + return folder + +def select_quality(qualities): + options = [] + + options.append([QUALITY_BEST, _.QUALITY_BEST]) + options.extend(qualities) + options.append([QUALITY_LOWEST, _.QUALITY_LOWEST]) + + values = [x[0] for x in options] + labels = [x[1] for x in options] + + current = userdata.get('last_quality') + + default = -1 + if current: + try: + default = values.index(current) + except: + default = values.index(qualities[-1][0]) + + for quality in qualities: + if quality[0] <= current: + default = values.index(quality[0]) + break + + index = gui.select(_.PLAYBACK_QUALITY, labels, preselect=default, autoclose=10000) #autoclose after 10seconds + if index < 0: + raise FailedPlayback('User cancelled quality select') + + userdata.set('last_quality', values[index]) + + return values[index] + +@plugin.route() +@plugin.login_required() +def play(asset_id, **kwargs): + use_ia_hls = settings.getBool('use_ia_hls') + stream_data = api.get_stream_data(asset_id) + token = userdata.get('access_token') + + play_item = plugin.Item( + art = False, + headers = {'Authorization': 'Bearer {}'.format(token)}, + cookies = {'access_token': token, 'client_id': CLIENT_ID}, + ) + + hls_url = stream_data.get('hls_url') + if hls_url: + play_item.path = hls_url + play_item.inputstream = inputstream.HLS(live=False) + return play_item + + stream_urls = stream_data.get('stream_urls', {}) + streams = stream_urls.get('Video') or stream_urls.get('Audio') or [] + + CODECS = { + 'libx264': 'H.264', + 'libx265': 'H.265', + } + + urls = [] + qualities = [] + for item in streams: + if item['type'] != 'application/x-mpegURL': + data = stream_data['data']['outputs'][item['label']] + + if data.get('migrated_from_non_labeled_conversions'): + bandwidth, resolution = BANDWIDTH_MAP.get(int(item['label'])) + codecs, fps = '', '' + else: + fps = _(_.QUALITY_FPS, fps=float(data['frame_rate'])) + resolution = '{}x{}'.format(data['width'], data['height']) + bandwidth = data['video_bitrate_in_kbps'] * 1000 #(or total_bitrate_in_kbps) + codecs = CODECS.get(data.get('video_codec'), '') + + urls.append([bandwidth, item['file']]) + qualities.append([bandwidth, _(_.QUALITY_BITRATE, bandwidth=float(bandwidth)/1000000, resolution=resolution, fps=fps, codecs=codecs)]) + + if not urls: + raise plugin.Error(_.NO_STREAM_ERROR) + + quality = kwargs.get(QUALITY_TAG) + if quality is None: + quality = settings.getEnum('default_quality', QUALITY_TYPES, default=QUALITY_ASK) + else: + quality = int(quality) + + urls = sorted(urls, key=lambda s: s[0], reverse=True) + qualities = sorted(qualities, key=lambda s: s[0], reverse=True) + + if quality == QUALITY_CUSTOM: + quality = int(settings.getFloat('max_bandwidth')*1000000) + elif quality == QUALITY_ASK: + quality = select_quality(qualities) + + if quality == QUALITY_BEST: + quality = qualities[0][0] + elif quality == QUALITY_LOWEST: + quality = qualities[-1][0] + + play_item.path = urls[-1][1] + for item in urls: + if item[0] <= quality: + play_item.path = item[1] + break + + return play_item + +h = HTMLParser() +def strip_tags(text): + if not text: + return '' + + text = re.sub('\([^\)]*\)', '', text) + text = re.sub('<[^>]*>', '', text) + text = h.unescape(text) + return text \ No newline at end of file diff --git a/plugin.video.udemy/resources/settings.xml b/plugin.video.udemy/resources/settings.xml new file mode 100644 index 00000000..623f1d18 --- /dev/null +++ b/plugin.video.udemy/resources/settings.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.willow.tv/__init__.py b/plugin.video.willow.tv/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.willow.tv/addon.xml b/plugin.video.willow.tv/addon.xml new file mode 100644 index 00000000..d8604307 --- /dev/null +++ b/plugin.video.willow.tv/addon.xml @@ -0,0 +1,24 @@ + + + + + + + video + + + + Watch all the top tier cricket from around the world in High Definition. + +Subscription required. + true + + + + Add Bookmarks + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/plugin.video.willow.tv/default.py b/plugin.video.willow.tv/default.py new file mode 100644 index 00000000..4ae9d04d --- /dev/null +++ b/plugin.video.willow.tv/default.py @@ -0,0 +1,5 @@ +import sys + +from resources.lib.plugin import plugin + +plugin.dispatch(sys.argv[2]) \ No newline at end of file diff --git a/plugin.video.willow.tv/fanart.jpg b/plugin.video.willow.tv/fanart.jpg new file mode 100644 index 00000000..39af10bb Binary files /dev/null and b/plugin.video.willow.tv/fanart.jpg differ diff --git a/plugin.video.willow.tv/icon.png b/plugin.video.willow.tv/icon.png new file mode 100644 index 00000000..7ff870b8 Binary files /dev/null and b/plugin.video.willow.tv/icon.png differ diff --git a/plugin.video.willow.tv/resources/__init__.py b/plugin.video.willow.tv/resources/__init__.py new file mode 100644 index 00000000..403f9800 --- /dev/null +++ b/plugin.video.willow.tv/resources/__init__.py @@ -0,0 +1 @@ +# Dummy diff --git a/plugin.video.willow.tv/resources/language/resource.language.en_gb/strings.po b/plugin.video.willow.tv/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..d855804c --- /dev/null +++ b/plugin.video.willow.tv/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,117 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "Email Address" +msgstr "" + +msgctxt "#30002" +msgid "Password" +msgstr "" + +msgctxt "#30003" +msgid "Failed to login.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30004" +msgid "Failed to get playback url.\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30005" +msgid "Live" +msgstr "" + +msgctxt "#30006" +msgid "Played" +msgstr "" + +msgctxt "#30007" +msgid "Upcoming" +msgstr "" + +msgctxt "#30008" +msgid "Show Scores" +msgstr "" + +msgctxt "#30009" +msgid "No Matches" +msgstr "" + +msgctxt "#30010" +msgid "Reminder Set" +msgstr "" + +msgctxt "#30011" +msgid "Reminder Removed" +msgstr "" + +msgctxt "#30012" +msgid "{label} [{start}]" +msgstr "" + +msgctxt "#30013" +msgid "DD MMM YYYY h:mma" +msgstr "" + +msgctxt "#30014" +msgid "{series}\n\n" +"{match}\n\n" +"{start}" +msgstr "" + +msgctxt "#30015" +msgid "{match} has just started" +msgstr "" + +msgctxt "#30016" +msgid "{label} [Part {part}]" +msgstr "" + +msgctxt "#30017" +msgid "Playback Source" +msgstr "" + +msgctxt "#30018" +msgid "Watch" +msgstr "" + +msgctxt "#30019" +msgid "Close" +msgstr "" + +msgctxt "#30020" +msgid "Your subscription does not have access to this content\n" +"Server Message: {msg}" +msgstr "" + +msgctxt "#30021" +msgid "Could not find video urls for this content" +msgstr "" + +## COMMON SETTINGS ## + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/plugin.video.willow.tv/resources/lib/__init__.py b/plugin.video.willow.tv/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin.video.willow.tv/resources/lib/api.py b/plugin.video.willow.tv/resources/lib/api.py new file mode 100644 index 00000000..5431475f --- /dev/null +++ b/plugin.video.willow.tv/resources/lib/api.py @@ -0,0 +1,106 @@ +import hashlib + +from slyguy import userdata +from slyguy.session import Session +from slyguy.exceptions import Error +from slyguy.mem_cache import cached + +from .constants import HEADERS, MD5_KEY, DEV_TYPE, LOGIN_URL, PLAYBACK_URL, LIVE_URL, UPCOMING_URL, ARCHIVE_URL, ARCHIVES_MATCH_URL +from .language import _ + +class APIError(Error): + pass + +class API(object): + def new_session(self): + self._session = Session(HEADERS) + self.logged_in = userdata.get('userid') != None + + def login(self, username, password): + self.logout() + + payload = { + 'action': 'login', + 'email': username, + 'password': password, + 'authToken': hashlib.md5('{}::{}::{}'.format(MD5_KEY, username, password).encode('utf8')).hexdigest(), + } + + data = self._session.post(LOGIN_URL, data=payload).json() + + if data['result']['status'] != 'success': + raise APIError(_(_.LOGIN_ERROR, msg=data['result'].get('message'))) + + userdata.set('userid', data['result']['userId']) + + def live_matches(self): + return self._session.get(LIVE_URL).json() + + @cached(60*10) + def played_series(self): + return self._session.get(ARCHIVE_URL).json() + + @cached(60*10) + def match(self, match_id): + return self._session.get(ARCHIVES_MATCH_URL.format(match_id=match_id)).json() + + def upcoming_matches(self): + return self._session.get(UPCOMING_URL).json() + + def get_series(self, series_id): + data = self.played_series() + + for row in data['vod']: + if row['sid'] == series_id: + return row + + return None + + def play_live(self, match_id, priority): + payload = { + 'mid': match_id, + 'type': 'live', + 'devType': DEV_TYPE, + 'pr': priority, + 'wuid': userdata.get('userid'), + } + + return self.play(payload) + + def play_replay(self, match_id, content_id): + payload = { + 'mid': match_id, + 'type': 'replay', + 'devType': DEV_TYPE, + 'title': content_id, + 'wuid': userdata.get('userid'), + } + + return self.play(payload) + + def play_highlight(self, match_id, content_id): + payload = { + 'mid': match_id, + 'type': 'highlight', + 'devType': DEV_TYPE, + 'id': content_id, + 'wuid': userdata.get('userid'), + } + + return self.play(payload) + + def play(self, payload): + data = self._session.post(PLAYBACK_URL, data=payload).json() + + if 'error' in data: + raise APIError(_(_.PLAYBACK_ERROR, msg=data['error'].get('title'))) + elif 'subscribe' in data: + raise APIError(_(_.SUBSCRIBE_ERROR, msg=data['subscribe'].get('description'))) + elif not data.get('Videos'): + raise APIError(_.NO_VIDEOS) + + return data['Videos'][0]['Url'] + + def logout(self): + userdata.delete('userid') + self.new_session() \ No newline at end of file diff --git a/plugin.video.willow.tv/resources/lib/constants.py b/plugin.video.willow.tv/resources/lib/constants.py new file mode 100644 index 00000000..6b061483 --- /dev/null +++ b/plugin.video.willow.tv/resources/lib/constants.py @@ -0,0 +1,18 @@ +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Linux; U; Android 5.1; en-US; SHIELD Android TV Build/LMY47D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/10.5.2.582 U3/0.8.0 Mobile Safari/534.30', + 'X-Forwarded-For': '72.229.28.185', +} + +MD5_KEY = '35a131404c264f36ca4031500143e4acf0682cd5' +LOGIN_URL = 'https://www.willow.tv/EventMgmt/webservices/mobi_auth.asp' +LIVE_URL = 'http://appfeeds.willow.tv/iptv/RokuLiveMatchList.json' +PLAYBACK_URL = 'http://www.willow.tv/iptvws/v1/playlist.asp' +UPCOMING_URL = 'http://appfeeds.willow.tv/RokuUpcoming.json' +ARCHIVE_URL = 'http://appfeeds.willow.tv/iptv/RokuSeriesList.json' +ARCHIVES_MATCH_URL = 'http://appfeeds.willow.tv/iptv/RokuMatchVODList_{match_id}.json' +# COUNTRY_CODE_URL = 'http://linsoftlayer.willow.tv/getCC.php' +# HIGHLIGHTS_URL = 'http://appfeeds.willow.tv/OttTrendingHighlights_{country_code}.json' +TEAMS_IMAGE_URL = 'https://aimages.willow.tv/AppleTvLogos/{team1}_{team2}.png' #.replace(" ","") + +DEV_TYPE = 'androidtv' +SERVICE_TIME = 300 \ No newline at end of file diff --git a/plugin.video.willow.tv/resources/lib/language.py b/plugin.video.willow.tv/resources/lib/language.py new file mode 100644 index 00000000..bb03b524 --- /dev/null +++ b/plugin.video.willow.tv/resources/lib/language.py @@ -0,0 +1,26 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + ASK_USERNAME = 30001 + ASK_PASSWORD = 30002 + LOGIN_ERROR = 30003 + PLAYBACK_ERROR = 30004 + LIVE = 30005 + PLAYED = 30006 + UPCOMING = 30007 + SHOW_SCORES = 30008 + NO_MATCHES = 30009 + REMINDER_SET = 30010 + REMINDER_REMOVED = 30011 + UPCOMING_MATCH = 30012 + DATE_FORMAT = 30013 + MATCH_PLOT = 30014 + MATCH_STARTED = 30015 + MULTIPART_VIDEO = 30016 + PLAYBACK_SOURCE = 30017 + WATCH = 30018 + CLOSE = 30019 + SUBSCRIBE_ERROR = 30020 + NO_VIDEOS = 30021 + +_ = Language() \ No newline at end of file diff --git a/plugin.video.willow.tv/resources/lib/plugin.py b/plugin.video.willow.tv/resources/lib/plugin.py new file mode 100644 index 00000000..e9a45b67 --- /dev/null +++ b/plugin.video.willow.tv/resources/lib/plugin.py @@ -0,0 +1,308 @@ +import json + +import arrow + +from slyguy import plugin, gui, settings, userdata, signals, inputstream +from slyguy.exceptions import PluginError +from slyguy.constants import PLAY_FROM_TYPES, PLAY_FROM_ASK, PLAY_FROM_LIVE, PLAY_FROM_START + +from .api import API +from .language import _ +from .constants import HEADERS, TEAMS_IMAGE_URL + +api = API() + +@signals.on(signals.BEFORE_DISPATCH) +def before_dispatch(): + api.new_session() + plugin.logged_in = api.logged_in + +@plugin.route('') +def home(**kwargs): + folder = plugin.Folder() + + if not api.logged_in: + folder.add_item(label=_(_.LOGIN, _bold=True), path=plugin.url_for(login), bookmark=False) + else: + folder.add_item(label=_(_.LIVE, _bold=True), path=plugin.url_for(live)) + folder.add_item(label=_(_.PLAYED, _bold=True), path=plugin.url_for(played)) + folder.add_item(label=_(_.UPCOMING, _bold=True), path=plugin.url_for(upcoming)) + + if settings.getBool('bookmarks', True): + folder.add_item(label=_(_.BOOKMARKS, _bold=True), path=plugin.url_for(plugin.ROUTE_BOOKMARKS), bookmark=False) + + folder.add_item(label=_.LOGOUT, path=plugin.url_for(logout), _kiosk=False, bookmark=False) + + folder.add_item(label=_.SETTINGS, path=plugin.url_for(plugin.ROUTE_SETTINGS), _kiosk=False, bookmark=False) + + return folder + +@plugin.route() +def live(**kwargs): + folder = plugin.Folder(_.LIVE, no_items_label=_.NO_MATCHES) + + data = api.live_matches() + for row in data['live']: + start = arrow.get(row['match_start_date']).to('local').format(_.DATE_FORMAT) + + sources = row['stream']['video_sources'] + priority = sources[0]['priority'] + + item = plugin.Item( + label = row['subtitle'], + info = {'plot': _(_.MATCH_PLOT, series=row['seriesName'], match=row['subtitle'], start=start)}, + art = {'thumb': TEAMS_IMAGE_URL.format(team1=row['team1'], team2=row['team2']).replace(' ', '')}, + path = plugin.url_for(play_live, match_id=row['mid'], priority=priority), + playable = True, + ) + + if len(sources) > 1: + url = plugin.url_for(select_source, match_id=row['mid'], sources=json.dumps(sources)) + item.context.append((_.PLAYBACK_SOURCE, 'PlayMedia({})'.format(url))) + + folder.add_items(item) + + return folder + +@plugin.route() +def select_source(match_id, sources, **kwargs): + match_id = int(match_id) + + sources = json.loads(sources) + + options = [x['priority'] for x in sources] + labels = [x['title'] for x in sources] + + index = gui.select(_.PLAYBACK_SOURCE, options=labels) + if index < 0: + return + + priority = int(options[index]) + + url = api.play_live(match_id, priority) + return _play(url) + +@plugin.route() +def played(**kwargs): + folder = plugin.Folder(_.PLAYED, no_items_label=_.NO_MATCHES) + + data = api.played_series() + for row in data['vod']: + folder.add_item( + label = row['title'], + art = {'thumb': row['img']}, + path = plugin.url_for(series, series_id=row['sid']), + ) + + return folder + +@plugin.route() +def series(series_id, **kwargs): + series_id = int(series_id) + + data = api.get_series(series_id) + folder = plugin.Folder(data['title']) + + for row in data['matches']: + start = arrow.get(row['match_start_date']).to('local').format(_.DATE_FORMAT) + + thumb = TEAMS_IMAGE_URL.format(team1=row['team1'], team2=row['team2']).replace(' ', '') + + folder.add_item( + label = row['subtitle'], + art = {'thumb': thumb}, + info = {'plot': _(_.MATCH_PLOT, series=row['seriesName'], match=row['subtitle'], start=start)}, + path = plugin.url_for(match, title=row['subtitle'], thumb=thumb, match_id=row['mid']), + ) + + return folder + +@plugin.route() +def match(match_id, title='', thumb='', index=None, **kwargs): + match_id = int(match_id) + + data = api.match(match_id) + folder = plugin.Folder(title) + + if not index: + for row in data['match']: + folder.add_item( + label = row['title'], + path = plugin.url_for(match, match_id=match_id, title=title, index=row['catIndex']), + ) + + return folder + + videos = [] + for row in data['match']: + if row['catIndex'] == index: + videos = row[row['catIndex']] + + for row in videos: + if 'plId' in row: + ids = [row['plId'],] + elif 'Ids' in row: + ids = row['Ids'] + else: + ids = [None,] + + for count, id in enumerate(ids): + if id == None: + path = None + elif index == 'highlight': + path = plugin.url_for(play_highlight, match_id=match_id, content_id=id) + elif index == 'replay': + path = plugin.url_for(play_replay, match_id=match_id, content_id=id) + else: + path = None + + label = row['title'] + if len(ids) > 1: + label = _(_.MULTIPART_VIDEO, label=label, part=count+1) + + folder.add_item( + label = label, + art = {'thumb': row['img']}, + path = path, + playable = path != None, + is_folder = False, + ) + + return folder + +@plugin.route() +def play_live(match_id, priority=1, **kwargs): + return _play_live(match_id, priority) + +def _play_live(match_id, priority=1): + match_id = int(match_id) + priority = int(priority) + + url = api.play_live(match_id, priority) + return _play(url, live=True) + +@plugin.route() +def play_highlight(match_id, content_id, **kwargs): + match_id = int(match_id) + content_id = int(content_id) + + url = api.play_highlight(match_id, content_id) + return _play(url) + +@plugin.route() +def play_replay(match_id, content_id, **kwargs): + match_id = int(match_id) + content_id = int(content_id) + + url = api.play_replay(match_id, content_id) + return _play(url) + +def _play(url, live=False): + return plugin.Item( + path = url, + headers = HEADERS, + inputstream = inputstream.HLS(live=live), + art = False, + ) + +@plugin.route() +def upcoming(**kwargs): + folder = plugin.Folder(_.UPCOMING, no_items_label=_.NO_MATCHES) + + alerts = userdata.get('alerts', []) + + data = api.upcoming_matches() + for row in data['upcoming']: + start = arrow.get(row['startDateTime']).to('local').format(_.DATE_FORMAT) + + if row['team1'] == 'TBC' or row['team2'] == 'TBC': + thumb = None + else: + thumb = TEAMS_IMAGE_URL.format(team1=row['team1'], team2=row['team2']).replace(' ', '') + + item = plugin.Item( + label = _(_.UPCOMING_MATCH, label=row['subtitle'], start=start), + art = {'thumb': thumb}, + info = {'plot': _(_.MATCH_PLOT, series=row['seriesName'], match=row['subtitle'], start=start)}, + path = plugin.url_for(alert, match_id=row['mid'], title=row['subtitle']), + playable = False, + is_folder = False, + ) + + if row['mid'] not in alerts: + item.info['playcount'] = 0 + else: + item.info['playcount'] = 1 + + folder.add_items(item) + + return folder + +@plugin.route() +def alert(match_id, title, **kwargs): + match_id = int(match_id) + + alerts = userdata.get('alerts', []) + + if match_id not in alerts: + alerts.append(match_id) + gui.notification(title, heading=_.REMINDER_SET) + else: + alerts.remove(match_id) + gui.notification(title, heading=_.REMINDER_REMOVED) + + userdata.set('alerts', alerts) + gui.refresh() + +@plugin.route() +def login(**kwargs): + username = gui.input(_.ASK_USERNAME, default=userdata.get('username', '')).strip() + if not username: + return + + userdata.set('username', username) + + password = gui.input(_.ASK_PASSWORD, hide_input=True).strip() + if not password: + return + + api.login(username=username, password=password) + gui.refresh() + +@plugin.route() +def logout(**kwargs): + if not gui.yes_no(_.LOGOUT_YES_NO): + return + + api.logout() + gui.refresh() + +@signals.on(signals.ON_SERVICE) +def service(): + alerts = userdata.get('alerts', []) + if not alerts: + return + + data = api.live_matches() + if not data['live']: + return + + notify = [] + + for row in data['live']: + if row['mid'] in alerts: + alerts.remove(row['mid']) + notify.append(row) + + userdata.set('alerts', alerts) + + for row in notify: + if not gui.yes_no(_(_.MATCH_STARTED, match=row['subtitle']), yeslabel=_.WATCH, nolabel=_.CLOSE): + continue + + with signals.throwable(): + sources = row['stream']['video_sources'] + priority = sources[0]['priority'] + + item = _play_live(match_id=row['mid'], priority=1) + item.play() \ No newline at end of file diff --git a/plugin.video.willow.tv/resources/settings.xml b/plugin.video.willow.tv/resources/settings.xml new file mode 100644 index 00000000..c4bda2b6 --- /dev/null +++ b/plugin.video.willow.tv/resources/settings.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugin.video.willow.tv/service.py b/plugin.video.willow.tv/service.py new file mode 100644 index 00000000..ef90c79f --- /dev/null +++ b/plugin.video.willow.tv/service.py @@ -0,0 +1,5 @@ +from slyguy.service import run + +from resources.lib.constants import SERVICE_TIME + +run(SERVICE_TIME) \ No newline at end of file diff --git a/repository.slyguy/addon.xml b/repository.slyguy/addon.xml new file mode 100644 index 00000000..7cba1b9d --- /dev/null +++ b/repository.slyguy/addon.xml @@ -0,0 +1,22 @@ + + + + + https://k.slyguy.xyz/.repo/addons.xml + https://k.slyguy.xyz/.repo/addons.xml.md5 + https://k.slyguy.xyz/.repo/ + + + + all + Addons by SlyGuy + + + + + + icon.png + fanart.jpg + + + \ No newline at end of file diff --git a/repository.slyguy/icon.png b/repository.slyguy/icon.png new file mode 100644 index 00000000..0caf1301 Binary files /dev/null and b/repository.slyguy/icon.png differ diff --git a/script.module.slyguy/__init__.py b/script.module.slyguy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/script.module.slyguy/addon.xml b/script.module.slyguy/addon.xml new file mode 100644 index 00000000..7cdc8be3 --- /dev/null +++ b/script.module.slyguy/addon.xml @@ -0,0 +1,23 @@ + + + + + + + + + + Common code used by all Slyguy add-ons + true + + + + + + icon.png + fanart.jpg + + + diff --git a/script.module.slyguy/default.py b/script.module.slyguy/default.py new file mode 100644 index 00000000..a2e19257 --- /dev/null +++ b/script.module.slyguy/default.py @@ -0,0 +1,9 @@ +import os +import sys + +path = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(1, os.path.realpath(os.path.join(path, 'resources/modules'))) + +from resources.lib import default + +default.run() \ No newline at end of file diff --git a/script.module.slyguy/icon.png b/script.module.slyguy/icon.png new file mode 100644 index 00000000..0caf1301 Binary files /dev/null and b/script.module.slyguy/icon.png differ diff --git a/script.module.slyguy/resources/__init__.py b/script.module.slyguy/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/script.module.slyguy/resources/language/resource.language.de_de/strings.po b/script.module.slyguy/resources/language/resource.language.de_de/strings.po new file mode 100644 index 00000000..e1ede7fe --- /dev/null +++ b/script.module.slyguy/resources/language/resource.language.de_de/strings.po @@ -0,0 +1,548 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "SlyGuy News" +msgstr "SlyGuy-Nachrichten" + +## COMMON ## + +msgctxt "#32000" +msgid "You need to login to access this content" +msgstr "Du musst dich anmelden um auf diesen Inhalt zuzugreifen." + +msgctxt "#32001" +msgid "No default route found" +msgstr "Keine Standardroute gefunden." + +msgctxt "#32002" +msgid "" +"Are you sure you want to reset this add-on?\n" +"This will reset all settings and clear all data" +msgstr "" +"Bist du sicher, dass du dieses Addon zurücksetzen willst?\n" +"Dies wird alle Einstellungen wiederherstellen und alle Daten löschen." + +msgctxt "#32003" +msgid "Reset Complete" +msgstr "Zurücksetzen abgeschlossen." + +msgctxt "#32004" +msgid "Removed {delete_count} Cached Item" +msgstr "{delete_count} gecachte Elemente gelöscht." + +msgctxt "#32005" +msgid "Clear Cache" +msgstr "Cache leeren" + +msgctxt "#32006" +msgid "" +"This add-on path is no longer valid\n" +"If your using favorites, remove the favorite and then re-create it via the " +"add-on" +msgstr "" +"Dieser Addon-Pfad existiert nicht mehr.\n" +"Wenn du Favoriten benutzt, lösche es und erstelle es erneut." + +msgctxt "#32007" +msgid "Could not find a url for function: {function_name}" +msgstr "Konnte keine URL finden für Funktion {function_name}" + +msgctxt "#32008" +msgid "Inpustream Adaptive add-on required for Widevine playback" +msgstr "Addon Inputstream Adaptive benötigt für Widevine-Wiedergabe." + +msgctxt "#32009" +msgid "" +"Kodi Windows Store (UWP) version does not support Widevine playback.\n" +"Use the Kodi .exe installer from the Kodi website instead." +msgstr "" +"Die Windows Store-Version (UWP) von Kodi unterstützt keine Widevine-" +"Wiedergabe. Benutze stattdessen den Kodi .exe-Installer von der der Kodi-" +"Webseite." + +msgctxt "#32010" +msgid "Kodi 18 or higher required for Widevine playback on {system}" +msgstr "Kodi 18 oder höher wird benötigt zur Widevine-Wiedergabe auf {system}" + +msgctxt "#32011" +msgid "" +"ARM 64bit does not support Widevine playback\n" +"An OS with a 32-bit userspace is required.\n" +"Try coreelec.org" +msgstr "" +"ARM 64 Bit unterstützt keine Widevine-Wiedergabe.\n" +"Ein Betriebssystem mit 32 Bit Userspace wird benötigt.\n" +"Teste coreelec.org" + +msgctxt "#32012" +msgid "" +"This system ({system}{arch} Kodi v{kodi_version}) does not yet support " +"Widevine playback" +msgstr "" +"Dieses System ({system}{arch} Kodi v{kodi_version}) unterstützt noch keine " +"Widevine-Wiedergabe." + +#msgctxt "#32013" +#msgid "" +#"Could not find a media source from Brightcove\n" +#"Server Message: {error}" +#msgstr "" + +msgctxt "#32014" +msgid "" +"Downloading required file\n" +"{url}" +msgstr "" +"Lade benötigte Datei\n" +"{url}" + +msgctxt "#32015" +msgid "Widevine CDM" +msgstr "Widevine CDM" + +msgctxt "#32016" +msgid "" +"There was an unexpected error installing the Widevine CDM\n" +"Please restart Kodi and try again" +msgstr "" +"Beim Installieren des Widevine CDM ist ein unerwarteter Fehler aufgetreten.\n" +"Bitte starte Kodi neu und versuche es erneut." + +msgctxt "#32017" +msgid "Use Cache" +msgstr "Cache benutzen" + +msgctxt "#32018" +msgid "Inputstream Settings" +msgstr "Inpustream-Einstellungen" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "Addon zurücksetzen" + +msgctxt "#32020" +msgid "{addon} - Error" +msgstr "{addon} - Fehler" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "Widevine CDM installieren" + +msgctxt "#32022" +msgid "" +"Widevine CDM ({version}) Installed OK\n" +"If videos do not play, please restart Kodi and try again" +msgstr "" +"Widevine CDM ({version}) korrekt installiert.\n" +"Wenn Videos nicht richtig spielen, starte Kodi neu und versuche es nochmal." + +msgctxt "#32023" +msgid "Use Inputstream HLS for VOD Streams" +msgstr "Benutze Inpustream HLS für VOD-Streams" + +msgctxt "#32024" +msgid "Login" +msgstr "Anmelden" + +msgctxt "#32025" +msgid "Logout" +msgstr "Abmelden" + +msgctxt "#32026" +msgid "Settings" +msgstr "Einstellungen" + +msgctxt "#32027" +msgid "Are you sure you want to logout?" +msgstr "Bist du sicher, dass du dich abmelden möchtest?" + +msgctxt "#32028" +msgid "" +"Failed to login.\n" +"Check your details are correct and try again." +msgstr "" +"Fehler beim Anmelden.\n" +"Überprüfe deine Anmeldedaten und versuche es erneut." + +msgctxt "#32029" +msgid "Search" +msgstr "Suchen" + +msgctxt "#32030" +msgid "Search: {query}" +msgstr "Suche: {query}" + +msgctxt "#32031" +msgid "No Results" +msgstr "Keine Ergebnisse" + +msgctxt "#32032" +msgid "{addon} ({version}) - Unexpected Error" +msgstr "{addon} ({version}) - Unerwarteter Fehler" + +msgctxt "#32033" +msgid "Failed to download required file: {filename}" +msgstr "" +"Lade benötigte Datei\n" +"{filename}" + +msgctxt "#32034" +msgid "General" +msgstr "Allgemein" + +msgctxt "#32035" +msgid "Playback" +msgstr "Wiedergabe" + +msgctxt "#32036" +msgid "Advanced" +msgstr "Erweitert" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "Überprüfe SSL-Zertifikate" + +msgctxt "#32038" +msgid "Select Widevine CDM version to install" +msgstr "Widevine CDM-Version zur Installation auswählen" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "Dienst-Startverzögerung (0 = zufällig 10s-60s)" + +msgctxt "#32040" +msgid "" +"MD5 checksum failed for file: {filename}\n" +"{local_md5} != {remote_md5}" +msgstr "" +"MD5-Prüfsumme ungültig für Datei {filename}\n" +"{local_md5} <> {remote_md5}" + +msgctxt "#32041" +msgid "No Items" +msgstr "Keine Elemente" + +msgctxt "#32042" +msgid "" +"New add-on not found.\n" +"Update repos then try again." +msgstr "" +"Neues Addon nicht gefunden.\n" +"Suche nach Aktualiserungen und versuche es erneut." + +msgctxt "#32043" +msgid "Best" +msgstr "Beste" + +#msgctxt "#32044" +#msgid "HTTP Timeout (seconds)" +#msgstr "" + +#msgctxt "#32045" +#msgid "HTTP Retries" +#msgstr "" + +#msgctxt "#32046" +#msgid "Chunksize" +#msgstr "" + +msgctxt "#32047" +msgid "{label} (LATEST)" +msgstr "{label} (neuste)" + +msgctxt "#32048" +msgid "Bypass" +msgstr "Umgehen" + +msgctxt "#32049" +msgid "No list item found that matches pattern: {pattern}" +msgstr "Kein Listenelement gefunden, dass auf Muster {pattern} passt" + +msgctxt "#32050" +msgid "" +"This add-on has moved\n" +"A new add-on '{new_addon_id}' is therefore required\n" +"Install and migrate to new add-on?" +msgstr "" +"Dieses Addon ist umgezogen.\n" +"Das neue Addon {new_addon_id} wird daher benötigt.\n" +"Neues Addon installieren und migrieren?" + +msgctxt "#32051" +msgid "" +"Add-on has been migrated\n" +"Remove the old add-on?" +msgstr "" +"Addon wurde migriert.\n" +"Altes Addon entfernen?" + +msgctxt "#32052" +msgid "Unknown Error" +msgstr "Unbekannter Fehler" + +msgctxt "#32053" +msgid "" +"This manifest contains multiple base urls\n" +"Support for this will hopefully be introduced in Kodi 19\n" +"There is a chance this content may not play" +msgstr "" +"Dieses Manifest enthält mehrere Basis-URLs.\n" +"Unterstützung dafür wird hoffentlich in Kodi 19 implementiert.\n" +"Es kann sein, dass der Inhalt nicht abgespielt wird.\n" + +msgctxt "#32054" +msgid "Custom" +msgstr "Angepasst" + +msgctxt "#32055" +msgid "Ask" +msgstr "Nachfragen" + +msgctxt "#32056" +msgid "" +"Failed to fetch / parse playback URL\n" +"Make sure your entitled to this content and your IP address is allowed (not " +"geo-blocked)\n" +"Try accessing the content via other means (offical app / website) to test if " +"it's a Kodi add-on issue" +msgstr "" +"Fehler beim Laden/Parsen der Wiedergabe-URL.\n" +"Versichere dich, dass du Zugriff auf diesen Inhalt hast und deine IP-Adresse " +"nicht geo-blockiert ist.\n" +"Versuche diesen Inhalt über die offizielle App oder Website abzurufen um zu " +"kontrollieren, dass es ein Problem in Kodi ist." + +msgctxt "#32057" +msgid "M3U8 file does not start with #EXTM3U" +msgstr "M3U8-Datei beginnt nicht mit #EXTM3U" + +msgctxt "#32058" +msgid "{version} (INSTALLED)" +msgstr "{version} (INSTALLED)" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "Max. Bandbreite (Mbit/s)" + +msgctxt "#32060" +msgid "Lowest" +msgstr "Niedrigste" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "Wiedergabequalität" + +#msgctxt "#32062" +#msgid "" +#"Inputstream HLS required to watch streams From Live.\n" +#"Check HLS is enabled in settings and Inputstream Adaptive is installed" +#msgstr "" + +#msgctxt "#32063" +#msgid "Live Play Action" +#msgstr "" + +#msgctxt "#32064" +#msgid "From Start" +#msgstr "" + +#msgctxt "#32065" +#msgid "From Live" +#msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "Nachfragen" + +msgctxt "#32067" +msgid "Play From?" +msgstr "Wiedergabe von?" + +msgctxt "#32068" +msgid "{bandwidth:.2f} Mbit/s {resolution} {fps}" +msgstr "{bandwidth:.2f} Mbit/s {resolution} {fps}" + +msgctxt "#32069" +msgid "{fps:.2f} FPS" +msgstr "{fps:.2f} FPS" + +msgctxt "#32070" +msgid "Available Widevine CDM Versions" +msgstr "Verfügbare Widevine CDM-Versionen" + +msgctxt "#32071" +msgid "Unknown {version} (INSTALLLED)" +msgstr "Unbekannt {version} (installiert)" + +msgctxt "#32072" +msgid "" +"Widevine CDM ({version}) Installed\n" +"If content does not play, you may need to install the latest CDM version " +"instead." +msgstr "" +"Widevine CDM ({version}) installiert.\n" +"Wenn der Inhalt nicht richtig abgespielt wird, musst du womöglich " +"stattdessen die neuste CDM-Version installieren." + +msgctxt "#32073" +msgid "Disabled" +msgstr "Deaktiviert" + +msgctxt "#32074" +msgid "URL returned error code: {code}" +msgstr "URL hat Fehlercode {code} zurückgegeben." + +msgctxt "#32075" +msgid "" +"Widevine CDM is built-in to Android\n" +"If content does not play, your Android device may not have a suitable " +"Widevine level." +msgstr "" +"Widevine CDM wird mit Android ausgeliefert.\n" +"Wenn der Inhalt nicht richtig abgespielt wird, hat dein Ger#te womöglich " +"kein ausreichendes Widevine-Level." + +msgctxt "#32076" +msgid "Use Inputstream HLS for Live Streams" +msgstr "Benutze Inpustream HLS für Livestreams" + +msgctxt "#32077" +msgid "" +"Your IP address is not allowed to access this content (geo-blocked)\n" +"You may need to use a VPN / Smart DNS. Sometimes even these can be blocked.\n" +"Try accessing the content via other means (offical app / website) to test if " +"it's a Kodi add-on issue" +msgstr "" +"Deine IP-Adresse darf diesen Inhalt nicht abspielen (Geo-Blocking).\n" +"Du musst eventuell ein VPN/Smart DNS benutzen. Auch diese werden manchmal " +"blockiert.\n" +"Versuche diesen Inhalt über die offizielle App oder Website abzurufen um zu " +"kontrollieren, dass es ein Problem in Kodi ist." + +msgctxt "#32078" +msgid "Kiosk Mode" +msgstr "" + +msgctxt "#32079" +msgid "Setup IPTV Merge" +msgstr "" + +msgctxt "#32080" +msgid "EPG days to scrape" +msgstr "" + +msgctxt "#32081" +msgid "Live TV & EPG" +msgstr "" + +msgctxt "#32082" +msgid "Force built-in EPG scraper" +msgstr "" + +msgctxt "#32083" +msgid "Profile Activated" +msgstr "" + +msgctxt "#32084" +msgid "Select Profile" +msgstr "Profil auswählen" + +msgctxt "#32085" +msgid "This service requires a {required} location\n" +"We detected your location is {current}\n" +msgstr "" + +msgctxt "#32086" +msgid "Audio Allow List" +msgstr "" + +msgctxt "#32087" +msgid "Subtitle Allow List" +msgstr "" + +msgctxt "#32088" +msgid "Include Forced Subs" +msgstr "" + +msgctxt "#32089" +msgid "Include Non-forced Subs" +msgstr "" + +msgctxt "#32090" +msgid "Include Audio Description Tracks" +msgstr "" + +msgctxt "#32091" +msgid "Add Profile" +msgstr "" + +msgctxt "#32092" +msgid "Delete Profile" +msgstr "" + +msgctxt "#32093" +msgid "Random Pick" +msgstr "" + +msgctxt "#32094" +msgid "Select Avatar" +msgstr "" + +msgctxt "#32095" +msgid "Select profile to delete" +msgstr "" + +msgctxt "#32096" +msgid "Profile history, watchlist, and activity will be deleted. There is no way to undo this." +msgstr "" + +msgctxt "#32097" +msgid "Delete {name}'s profile?" +msgstr "" + +msgctxt "#32098" +msgid "Profile Deleted" +msgstr "" + +msgctxt "#32099" +msgid "{label} (Used)" +msgstr "" + +msgctxt "#32100" +msgid "Kid friendly interface with only content suitable for kids." +msgstr "" + +msgctxt "#32101" +msgid "Kids Profile?" +msgstr "" + +msgctxt "#32102" +msgid "Profile Name" +msgstr "" + +msgctxt "#32103" +msgid "{name} is already being used" +msgstr "" + +msgctxt "#32104" +msgid "Disable profile switching in Kids profile" +msgstr "Profilwechsel in Kinderprofil verbieten" + +msgctxt "#32105" +msgid "Content requires a newer version of Inputstream Adaptive\n" +"Minimum required version: [B]{required}[/B]\n" +"Your current version: {current}" +msgstr "" + +msgctxt "#32106" +msgid "ARMv6 does not support Widevine playback" +msgstr "" + +msgctxt "#32107" +msgid "iOS does not support Widevine playback on Kodi" +msgstr "" + +msgctxt "#32108" +msgid "Next Page ({page})" +msgstr "Nächste Seite ({page})" \ No newline at end of file diff --git a/script.module.slyguy/resources/language/resource.language.en_gb/strings.po b/script.module.slyguy/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 00000000..3a18ab33 --- /dev/null +++ b/script.module.slyguy/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,518 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "SlyGuy News" +msgstr "" + +## COMMON ## + +msgctxt "#32000" +msgid "You need to login to access this content" +msgstr "" + +msgctxt "#32001" +msgid "No default route found" +msgstr "" + +msgctxt "#32002" +msgid "Are you sure you want to reset this add-on?\n" +"This will reset all settings and clear all data" +msgstr "" + +msgctxt "#32003" +msgid "Reset Complete" +msgstr "" + +msgctxt "#32004" +msgid "Removed {delete_count} Cached Item" +msgstr "" + +msgctxt "#32005" +msgid "Clear Cache" +msgstr "" + +msgctxt "#32006" +msgid "This add-on path is no longer valid\n" +"If your using favorites, remove the favorite and then re-create it via the add-on" +msgstr "" + +msgctxt "#32007" +msgid "Could not find a url for function: {function_name}" +msgstr "" + +msgctxt "#32008" +msgid "{addon_id} add-on is required" +msgstr "" + +msgctxt "#32009" +msgid "Kodi Microsoft UWP version does not support Widevine playback.\n" +"If using Windows, use the .exe installer version from the Kodi website instead." +msgstr "" + +msgctxt "#32010" +msgid "Kodi 18 or higher required for Widevine playback on {system}" +msgstr "" + +msgctxt "#32011" +msgid "ARM 64bit does not support Widevine playback\n" +"An OS with a 32-bit userspace is required.\n" +"Try coreelec.org" +msgstr "" + +msgctxt "#32012" +msgid "{system} {arch} Kodi v{kodi_version}\ndoes not yet support Widevine playback" +msgstr "" + +msgctxt "#32013" +msgid "Could not find a media source from Brightcove\n" +"Server Message: {error}" +msgstr "" + +msgctxt "#32014" +msgid "Downloading required file\n" +"{url}" +msgstr "" + +msgctxt "#32015" +msgid "Widevine CDM" +msgstr "" + +msgctxt "#32016" +msgid "There was an unexpected error installing the Widevine CDM\n" +"Please restart Kodi and try again" +msgstr "" + +msgctxt "#32017" +msgid "Use Cache" +msgstr "" + +msgctxt "#32018" +msgid "Inputstream Settings" +msgstr "" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "" + +msgctxt "#32020" +msgid "{addon} - Error" +msgstr "" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "" + +msgctxt "#32022" +msgid "Widevine CDM ({version}) Installed OK\n" +"If videos do not play, please restart Kodi and try again" +msgstr "" + +msgctxt "#32023" +msgid "Use Inputstream HLS for VOD Streams" +msgstr "" + +msgctxt "#32024" +msgid "Login" +msgstr "" + +msgctxt "#32025" +msgid "Logout" +msgstr "" + +msgctxt "#32026" +msgid "Settings" +msgstr "" + +msgctxt "#32027" +msgid "Are you sure you want to logout?" +msgstr "" + +msgctxt "#32028" +msgid "Failed to login.\n" +"Check your details are correct and try again." +msgstr "" + +msgctxt "#32029" +msgid "Search" +msgstr "" + +msgctxt "#32030" +msgid "Search: {query}" +msgstr "" + +msgctxt "#32031" +msgid "No Results" +msgstr "" + +msgctxt "#32032" +msgid "{addon} ({version}) - Unexpected Error" +msgstr "" + +msgctxt "#32033" +msgid "Failed to download required file: {filename}" +msgstr "" + +msgctxt "#32034" +msgid "General" +msgstr "" + +msgctxt "#32035" +msgid "Playback" +msgstr "" + +msgctxt "#32036" +msgid "Advanced" +msgstr "" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "" + +msgctxt "#32038" +msgid "Select Widevine CDM version to install" +msgstr "" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "" + +msgctxt "#32040" +msgid "MD5 checksum failed for file: {filename}\n" +"{local_md5} != {remote_md5}" +msgstr "" + +msgctxt "#32041" +msgid "No Items" +msgstr "" + +msgctxt "#32042" +msgid "New add-on not found.\n" +"Update repos then try again." +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "" + +msgctxt "#32047" +msgid "{label} (LATEST)" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32049" +msgid "\"{choose}\" item not found that matches pattern \"{pattern}\"" +msgstr "" + +msgctxt "#32050" +msgid "This add-on has moved\n" +"A new add-on '{new_addon_id}' is therefore required\n" +"Install and migrate to new add-on?" +msgstr "" + +msgctxt "#32051" +msgid "Add-on has been migrated\n" +"Remove the old add-on?" +msgstr "" + +msgctxt "#32052" +msgid "Unknown Error" +msgstr "" + +msgctxt "#32053" +msgid "This manifest contains multiple base urls\n" +"Support for this will hopefully be introduced in Kodi 19\n" +"There is a chance this content may not play" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32056" +msgid "Failed to fetch / parse playback URL\n" +"Make sure your entitled to this content and your IP address is allowed (not geo-blocked)\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "" + +msgctxt "#32057" +msgid "M3U8 file does not start with #EXTM3U" +msgstr "" + +msgctxt "#32058" +msgid "{version} (INSTALLED)" +msgstr "" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "" + +msgctxt "#32062" +msgid "Inputstream HLS required to watch streams From Live.\n" +"Check HLS is enabled in settings and Inputstream Adaptive is installed" +msgstr "" + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32067" +msgid "Play From?" +msgstr "" + +msgctxt "#32068" +msgid "{bandwidth:.2f} Mbit/s {resolution} {fps} {codecs}" +msgstr "" + +msgctxt "#32069" +msgid "{fps:.2f} FPS" +msgstr "" + +msgctxt "#32070" +msgid "Available Widevine CDM Versions" +msgstr "" + +msgctxt "#32071" +msgid "Unknown {version} (INSTALLLED)" +msgstr "" + +msgctxt "#32072" +msgid "Widevine CDM ({version}) Installed\n" +"If content does not play, you may need to install the latest CDM version instead." +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32074" +msgid "URL returned error code: {code}" +msgstr "" + +msgctxt "#32075" +msgid "Widevine CDM is built-in to Android\n" +"If content does not play, your Android device may not have a suitable Widevine level." +msgstr "" + +msgctxt "#32076" +msgid "Use Inputstream HLS for Live Streams" +msgstr "" + +msgctxt "#32077" +msgid "Your IP address is not allowed to access this content (geo-blocked)\n" +"You may need to use a VPN / Smart DNS. Sometimes even these can be blocked.\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "" + +msgctxt "#32078" +msgid "Kiosk Mode" +msgstr "" + +msgctxt "#32079" +msgid "Setup IPTV Merge" +msgstr "" + +msgctxt "#32080" +msgid "Max EPG Days to Scrape" +msgstr "" + +msgctxt "#32081" +msgid "Live TV & EPG" +msgstr "" + +msgctxt "#32082" +msgid "Force built-in EPG scraper" +msgstr "" + +msgctxt "#32083" +msgid "Profile Activated" +msgstr "" + +msgctxt "#32084" +msgid "Select Profile" +msgstr "" + +msgctxt "#32085" +msgid "This service requires a {required} location\n" +"We detected your location is {current}\n" +msgstr "" + +msgctxt "#32086" +msgid "Audio Allow List" +msgstr "" + +msgctxt "#32087" +msgid "Subtitle Allow List" +msgstr "" + +msgctxt "#32088" +msgid "Include Forced Subs" +msgstr "" + +msgctxt "#32089" +msgid "Include Non-forced Subs" +msgstr "" + +msgctxt "#32090" +msgid "Include Audio Description Tracks" +msgstr "" + +msgctxt "#32091" +msgid "Add Profile" +msgstr "" + +msgctxt "#32092" +msgid "Delete Profile" +msgstr "" + +msgctxt "#32093" +msgid "Random Pick" +msgstr "" + +msgctxt "#32094" +msgid "Select Avatar" +msgstr "" + +msgctxt "#32095" +msgid "Select profile to delete" +msgstr "" + +msgctxt "#32096" +msgid "Profile history, watchlist, and activity will be deleted. There is no way to undo this." +msgstr "" + +msgctxt "#32097" +msgid "Delete {name}'s profile?" +msgstr "" + +msgctxt "#32098" +msgid "Profile Deleted" +msgstr "" + +msgctxt "#32099" +msgid "{label} (Used)" +msgstr "" + +msgctxt "#32100" +msgid "Kid friendly interface with only content suitable for kids." +msgstr "" + +msgctxt "#32101" +msgid "Kids Profile?" +msgstr "" + +msgctxt "#32102" +msgid "Profile Name" +msgstr "" + +msgctxt "#32103" +msgid "{name} is already being used" +msgstr "" + +msgctxt "#32104" +msgid "Disable profile switching in Kids profile" +msgstr "" + +msgctxt "#32105" +msgid "Content requires a newer version of Inputstream Adaptive\n" +"Minimum required version: [B]{required}[/B]\n" +"Your current version: {current}" +msgstr "" + +msgctxt "#32106" +msgid "ARMv6 does not support Widevine playback" +msgstr "" + +msgctxt "#32107" +msgid "iOS does not support Widevine playback on Kodi" +msgstr "" + +msgctxt "#32108" +msgid "Next Page ({page})" +msgstr "" + +msgctxt "#32109" +msgid "Failed to fetch JSON data\n" +"Make sure your IP address is allowed (not geo-blocked)\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "" + +msgctxt "#32110" +msgid "URL returned no response\n" +"Check your internet connection and make sure your IP address is allowed (not geo-blocked)" +msgstr "" + +msgctxt "#32111" +msgid "Bookmarks" +msgstr "" + +msgctxt "#32112" +msgid "Add Bookmark" +msgstr "" + +msgctxt "#32113" +msgid "Remove Bookmark" +msgstr "" + +msgctxt "#32114" +msgid "Bookmark Added" +msgstr "" + +msgctxt "#32115" +msgid "Move Up" +msgstr "" + +msgctxt "#32116" +msgid "Move Down" +msgstr "" + +msgctxt "#32117" +msgid "Rename Bookmark" +msgstr "" + +msgctxt "#32118" +msgid "XZ extracting requires Kodi 19 or above" +msgstr "" + +msgctxt "#32119" +msgid "Attempting to install Inputstream Adaptive from apt..." +msgstr "" \ No newline at end of file diff --git a/script.module.slyguy/resources/language/resource.language.fr_ca/strings.po b/script.module.slyguy/resources/language/resource.language.fr_ca/strings.po new file mode 100644 index 00000000..f8278a35 --- /dev/null +++ b/script.module.slyguy/resources/language/resource.language.fr_ca/strings.po @@ -0,0 +1,471 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "SlyGuy News" +msgstr "" + +## COMMON ## + +msgctxt "#32000" +msgid "You need to login to access this content" +msgstr "" + +msgctxt "#32001" +msgid "No default route found" +msgstr "" + +msgctxt "#32002" +msgid "Are you sure you want to reset this add-on?\n" +"This will reset all settings and clear all data" +msgstr "" + +msgctxt "#32003" +msgid "Reset Complete" +msgstr "" + +msgctxt "#32004" +msgid "Removed {delete_count} Cached Item" +msgstr "" + +msgctxt "#32005" +msgid "Clear Cache" +msgstr "" + +msgctxt "#32006" +msgid "This add-on path is no longer valid\n" +"If your using favorites, remove the favorite and then re-create it via the add-on" +msgstr "" + +msgctxt "#32007" +msgid "Could not find a url for function: {function_name}" +msgstr "" + +msgctxt "#32008" +msgid "{addon_id} add-on is required" +msgstr "" + +msgctxt "#32009" +msgid "Kodi Microsoft UWP version does not support Widevine playback.\n" +"If using Windows, use the .exe installer version from the Kodi website instead." +msgstr "" + +msgctxt "#32010" +msgid "Kodi 18 or higher required for Widevine playback on {system}" +msgstr "" + +msgctxt "#32011" +msgid "ARM 64bit does not support Widevine playback\n" +"An OS with a 32-bit userspace is required.\n" +"Try coreelec.org" +msgstr "" + +msgctxt "#32012" +msgid "{system} {arch} Kodi v{kodi_version}\ndoes not yet support Widevine playback" +msgstr "" + +msgctxt "#32013" +msgid "Could not find a media source from Brightcove\n" +"Server Message: {error}" +msgstr "" + +msgctxt "#32014" +msgid "Downloading required file\n" +"{url}" +msgstr "" + +msgctxt "#32015" +msgid "Widevine CDM" +msgstr "" + +msgctxt "#32016" +msgid "There was an unexpected error installing the Widevine CDM\n" +"Please restart Kodi and try again" +msgstr "" + +msgctxt "#32017" +msgid "Use Cache" +msgstr "" + +msgctxt "#32018" +msgid "Inputstream Settings" +msgstr "" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "" + +msgctxt "#32020" +msgid "{addon} - Error" +msgstr "" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "" + +msgctxt "#32022" +msgid "Widevine CDM ({version}) Installed OK\n" +"If videos do not play, please restart Kodi and try again" +msgstr "" + +msgctxt "#32023" +msgid "Use Inputstream HLS for VOD Streams" +msgstr "" + +msgctxt "#32024" +msgid "Login" +msgstr "" + +msgctxt "#32025" +msgid "Logout" +msgstr "" + +msgctxt "#32026" +msgid "Settings" +msgstr "" + +msgctxt "#32027" +msgid "Are you sure you want to logout?" +msgstr "" + +msgctxt "#32028" +msgid "Failed to login.\n" +"Check your details are correct and try again." +msgstr "" + +msgctxt "#32029" +msgid "Search" +msgstr "" + +msgctxt "#32030" +msgid "Search: {query}" +msgstr "" + +msgctxt "#32031" +msgid "No Results" +msgstr "" + +msgctxt "#32032" +msgid "{addon} ({version}) - Unexpected Error" +msgstr "" + +msgctxt "#32033" +msgid "Failed to download required file: {filename}" +msgstr "" + +msgctxt "#32034" +msgid "General" +msgstr "" + +msgctxt "#32035" +msgid "Playback" +msgstr "" + +msgctxt "#32036" +msgid "Advanced" +msgstr "" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "" + +msgctxt "#32038" +msgid "Select Widevine CDM version to install" +msgstr "" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "" + +msgctxt "#32040" +msgid "MD5 checksum failed for file: {filename}\n" +"{local_md5} != {remote_md5}" +msgstr "" + +msgctxt "#32041" +msgid "No Items" +msgstr "" + +msgctxt "#32042" +msgid "New add-on not found.\n" +"Update repos then try again." +msgstr "" + +msgctxt "#32043" +msgid "Best" +msgstr "" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "" + +msgctxt "#32047" +msgid "{label} (LATEST)" +msgstr "" + +msgctxt "#32048" +msgid "Bypass" +msgstr "" + +msgctxt "#32049" +msgid "\"{choose}\" item not found that matches pattern \"{pattern}\"" +msgstr "" + +msgctxt "#32050" +msgid "This add-on has moved\n" +"A new add-on '{new_addon_id}' is therefore required\n" +"Install and migrate to new add-on?" +msgstr "" + +msgctxt "#32051" +msgid "Add-on has been migrated\n" +"Remove the old add-on?" +msgstr "" + +msgctxt "#32052" +msgid "Unknown Error" +msgstr "" + +msgctxt "#32053" +msgid "This manifest contains multiple base urls\n" +"Support for this will hopefully be introduced in Kodi 19\n" +"There is a chance this content may not play" +msgstr "" + +msgctxt "#32054" +msgid "Custom" +msgstr "" + +msgctxt "#32055" +msgid "Ask" +msgstr "" + +msgctxt "#32056" +msgid "Failed to fetch / parse playback URL\n" +"Make sure your entitled to this content and your IP address is allowed (not geo-blocked)\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "" + +msgctxt "#32057" +msgid "M3U8 file does not start with #EXTM3U" +msgstr "" + +msgctxt "#32058" +msgid "{version} (INSTALLED)" +msgstr "" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "" + +msgctxt "#32060" +msgid "Lowest" +msgstr "" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "" + +msgctxt "#32062" +msgid "Inputstream HLS required to watch streams From Live.\n" +"Check HLS is enabled in settings and Inputstream Adaptive is installed" +msgstr "" + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "" + +msgctxt "#32065" +msgid "From Live" +msgstr "" + +msgctxt "#32066" +msgid "Ask" +msgstr "" + +msgctxt "#32067" +msgid "Play From?" +msgstr "" + +msgctxt "#32068" +msgid "{bandwidth:.2f} Mbit/s {resolution} {fps}" +msgstr "" + +msgctxt "#32069" +msgid "{fps:.2f} FPS" +msgstr "" + +msgctxt "#32070" +msgid "Available Widevine CDM Versions" +msgstr "" + +msgctxt "#32071" +msgid "Unknown {version} (INSTALLLED)" +msgstr "" + +msgctxt "#32072" +msgid "Widevine CDM ({version}) Installed\n" +"If content does not play, you may need to install the latest CDM version instead." +msgstr "" + +msgctxt "#32073" +msgid "Disabled" +msgstr "" + +msgctxt "#32074" +msgid "URL returned error code: {code}" +msgstr "" + +msgctxt "#32075" +msgid "Widevine CDM is built-in to Android\n" +"If content does not play, your Android device may not have a suitable Widevine level." +msgstr "" + +msgctxt "#32076" +msgid "Use Inputstream HLS for Live Streams" +msgstr "" + +msgctxt "#32077" +msgid "Your IP address is not allowed to access this content (geo-blocked)\n" +"You may need to use a VPN / Smart DNS. Sometimes even these can be blocked.\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "" + +msgctxt "#32078" +msgid "Kiosk Mode" +msgstr "" + +msgctxt "#32079" +msgid "Setup IPTV Merge" +msgstr "" + +msgctxt "#32080" +msgid "Max EPG Days to Scrape" +msgstr "" + +msgctxt "#32081" +msgid "Live TV & EPG" +msgstr "" + +msgctxt "#32082" +msgid "Force built-in EPG scraper" +msgstr "" + +msgctxt "#32083" +msgid "Profile Activated" +msgstr "" + +msgctxt "#32084" +msgid "Select Profile" +msgstr "" + +msgctxt "#32085" +msgid "This service requires a {required} location\n" +"We detected your location is {current}\n" +msgstr "" + +msgctxt "#32086" +msgid "Audio Allow List" +msgstr "" + +msgctxt "#32087" +msgid "Subtitle Allow List" +msgstr "" + +msgctxt "#32088" +msgid "Include Forced Subs" +msgstr "" + +msgctxt "#32089" +msgid "Include Non-forced Subs" +msgstr "" + +msgctxt "#32090" +msgid "Include Audio Description Tracks" +msgstr "" + +msgctxt "#32091" +msgid "Add Profile" +msgstr "" + +msgctxt "#32092" +msgid "Delete Profile" +msgstr "" + +msgctxt "#32093" +msgid "Random Pick" +msgstr "" + +msgctxt "#32094" +msgid "Select Avatar" +msgstr "" + +msgctxt "#32095" +msgid "Select profile to delete" +msgstr "" + +msgctxt "#32096" +msgid "Profile history, watchlist, and activity will be deleted. There is no way to undo this." +msgstr "" + +msgctxt "#32097" +msgid "Delete {name}'s profile?" +msgstr "" + +msgctxt "#32098" +msgid "Profile Deleted" +msgstr "" + +msgctxt "#32099" +msgid "{label} (Used)" +msgstr "" + +msgctxt "#32100" +msgid "Kid friendly interface with only content suitable for kids." +msgstr "" + +msgctxt "#32101" +msgid "Kids Profile?" +msgstr "" + +msgctxt "#32102" +msgid "Profile Name" +msgstr "" + +msgctxt "#32103" +msgid "{name} is already being used" +msgstr "" + +msgctxt "#32104" +msgid "Disable profile switching in Kids profile" +msgstr "" + +msgctxt "#32105" +msgid "Content requires a newer version of Inputstream Adaptive\n" +"Minimum required version: [B]{required}[/B]\n" +"Your current version: {current}" +msgstr "" + +msgctxt "#32106" +msgid "ARMv6 does not support Widevine playback" +msgstr "" + +msgctxt "#32107" +msgid "iOS does not support Widevine playback on Kodi" +msgstr "" + +msgctxt "#32108" +msgid "Next Page ({page})" +msgstr "Page suivante ({page})" \ No newline at end of file diff --git a/script.module.slyguy/resources/language/resource.language.it_it/strings.po b/script.module.slyguy/resources/language/resource.language.it_it/strings.po new file mode 100644 index 00000000..095f2104 --- /dev/null +++ b/script.module.slyguy/resources/language/resource.language.it_it/strings.po @@ -0,0 +1,495 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "SlyGuy News" +msgstr "SlyGuy Novità" + +## COMMON ## + +msgctxt "#32000" +msgid "You need to login to access this content" +msgstr "Devi accedere per visualizzare questo contenuto" + +msgctxt "#32001" +msgid "No default route found" +msgstr "Nessun percorso predefinito trovato" + +msgctxt "#32002" +msgid "Are you sure you want to reset this add-on?\n" +"This will reset all settings and clear all data" +msgstr "Sei sicuro di voler ripristinare questo componente aggiuntivo?\n" +"Saranno ripristinate tutte le impostazioni e cancellati tutti i dati" + +msgctxt "#32003" +msgid "Reset Complete" +msgstr "Ripristino completato" + +msgctxt "#32004" +msgid "Removed {delete_count} Cached Item" +msgstr "Elemento {delete_count} memorizzato nella cache rimosso" + +msgctxt "#32005" +msgid "Clear Cache" +msgstr "Cancella cache" + +msgctxt "#32006" +msgid "This add-on path is no longer valid\n" +"If your using favorites, remove the favorite and then re-create it via the add-on" +msgstr "Questo percorso del componente aggiuntivo non è più valido\n" +"Se si utilizzano i preferiti, rimuovere il preferito e ricrearlo tramite il componente aggiuntivo" + +msgctxt "#32007" +msgid "Could not find a url for function: {function_name}" +msgstr "Impossibile trovare un URL per la funzione: {function_name}" + +msgctxt "#32008" +msgid "Inpustream Adaptive add-on required for Widevine playback" +msgstr "Inpustream Adaptive per la riproduzione richiede Widevine" + +msgctxt "#32009" +msgid "Kodi Windows Store (UWP) version does not support Widevine playback.\n" +"Use the Kodi .exe installer from the Kodi website instead." +msgstr "La versione Kodi del Windows Store (UWP) non supporta la riproduzione Widevine.\n" +"Si consiglia invece di installare Kodi .exe dal sito Web ufficiale di Kodi. + +msgctxt "#32010" +msgid "Kodi 18 or higher required for Widevine playback on {system}" +msgstr "Kodi 18 o superiore è richiesto per la riproduzione Widevine su {system}" + +msgctxt "#32011" +msgid "ARM 64bit does not support Widevine playback\n" +"An OS with a 32-bit userspace is required.\n" +"Try coreelec.org" +msgstr "ARM 64bit non supporta la riproduzione Widevine\n" +"È richiesto un sistema operativo a 32 bit.\n" +"Prova coreelec.org" + +msgctxt "#32012" +msgid "This system ({system}{arch} Kodi v{kodi_version}) does not yet support Widevine playback" +msgstr "Questo sistema ({system}{arch} Kodi v{kodi_version}) non supporta ancora la riproduzione Widevine" + +msgctxt "#32013" +msgid "Could not find a media source from Brightcove\n" +"Server Message: {error}" +msgstr "Impossibile trovare un'origine multimediale da Brightcove\n" +"Messaggio del server: {error}" + +msgctxt "#32014" +msgid "Downloading required file\n" +"{url}" +msgstr "Download del file richiesto\n" +"{url}" + +msgctxt "#32015" +msgid "Widevine CDM" +msgstr "Widevine CDM" + +msgctxt "#32016" +msgid "There was an unexpected error installing the Widevine CDM\n" +"Please restart Kodi and try again" +msgstr "Si è verificato un errore imprevisto durante l'installazione di Widevine CDM" +"Riavvia Kodi e riprova" + +msgctxt "#32017" +msgid "Use Cache" +msgstr "Usa la cache" + +msgctxt "#32018" +msgid "Inputstream Settings" +msgstr "Impostazioni Inputstream" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "Ripristina il componente aggiuntivo" + +msgctxt "#32020" +msgid "{addon} - Error" +msgstr "{addon} - Errore" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "Installa Widevine CDM" + +msgctxt "#32022" +msgid "Widevine CDM ({version}) Installed OK\n" +"If videos do not play, please restart Kodi and try again" +msgstr "Widevine CDM ({version}) Installato\n" +"Se i video non vengono riprodotti, riavvia Kodi e riprova" + +msgctxt "#32023" +msgid "Use Inputstream HLS for VOD Streams" +msgstr "Usa Inputstream HLS per flussi VOD" + +msgctxt "#32024" +msgid "Login" +msgstr "Accesso" + +msgctxt "#32025" +msgid "Logout" +msgstr "Disconnettersi" + +msgctxt "#32026" +msgid "Settings" +msgstr "Impostazioni" + +msgctxt "#32027" +msgid "Are you sure you want to logout?" +msgstr "Sei sicuro di voler uscire?" + +msgctxt "#32028" +msgid "Failed to login.\n" +"Check your details are correct and try again." +msgstr "Accesso non riuscito.\n" +"Verifica che i tuoi dati siano corretti e riprova." + +msgctxt "#32029" +msgid "Search" +msgstr "Cerca" + +msgctxt "#32030" +msgid "Search: {query}" +msgstr "Cerca: {query}" + +msgctxt "#32031" +msgid "No Results" +msgstr "Nessun risultato" + +msgctxt "#32032" +msgid "{addon} ({version}) - Unexpected Error" +msgstr "{addon} ({version}) - Errore inaspettato" + +msgctxt "#32033" +msgid "Failed to download required file: {filename}" +msgstr "Impossibile scaricare il file richiesto: {filename}" + +msgctxt "#32034" +msgid "General" +msgstr "Generale" + +msgctxt "#32035" +msgid "Playback" +msgstr "Riproduzione" + +msgctxt "#32036" +msgid "Advanced" +msgstr "Avanzate" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "Verifica certificati SSL" + +msgctxt "#32038" +msgid "Select Widevine CDM version to install" +msgstr "Seleziona la versione Widevine CDM da installare" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "Ritardo avvio servizio (0 = Random 10s-60s)" + +msgctxt "#32040" +msgid "MD5 checksum failed for file: {filename}\n" +"{local_md5} != {remote_md5}" +msgstr "MD5 checksum fallito per il file: {filename}\n" +"{local_md5} != {remote_md5}" + +msgctxt "#32041" +msgid "No Items" +msgstr "Nessun oggetto" + +msgctxt "#32042" +msgid "New add-on not found.\n" +"Update repos then try again." +msgstr "Nuovo componente aggiuntivo non trovato" +"Aggiorna i repository, quindi riprova." + +msgctxt "#32043" +msgid "Best" +msgstr "Migliore" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "HTTP Tempo scaduto (secondi)" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "HTTP Tentativo" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "Dimensione" + +msgctxt "#32047" +msgid "{label} (LATEST)" +msgstr "{label} (Ultima)" + +msgctxt "#32048" +msgid "Bypass" +msgstr "Bypass" + +msgctxt "#32049" +msgid "\"{choose}\" item not found that matches pattern \"{pattern}\"" +msgstr "\"{choose}\" elemento corrispondente non trovato \"{pattern}\"" + +msgctxt "#32050" +msgid "This add-on has moved\n" +"A new add-on '{new_addon_id}' is therefore required\n" +"Install and migrate to new add-on?" +msgstr "Questo componente aggiuntivo è da aggiornare\n" +"La nuova versione '{new_addon_id}' è quindi richiesta\n" +"Vuoi installare la nuova versione del componente aggiuntivo?" + +msgctxt "#32051" +msgid "Add-on has been migrated\n" +"Remove the old add-on?" +msgstr "Il componente aggiuntivo è stato aggiornato" +"Vuoi aggiornare il vecchio componente aggiuntivo?" + +msgctxt "#32052" +msgid "Unknown Error" +msgstr "Errore sconosciuto" + +msgctxt "#32053" +msgid "This manifest contains multiple base urls\n" +"Support for this will hopefully be introduced in Kodi 19\n" +"There is a chance this content may not play" +msgstr "Questo oggetto contiene più fonti urls\n" +"Si spera che il supporto sarà introdotto in Kodi 19\n" +"È possibile che questo contenuto non venga riprodotto" + +msgctxt "#32054" +msgid "Custom" +msgstr "Utente" + +msgctxt "#32055" +msgid "Ask" +msgstr "Chiedi" + +msgctxt "#32056" +msgid "Failed to fetch / parse playback URL\n" +"Make sure your entitled to this content and your IP address is allowed (not geo-blocked)\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "Impossibile recuperare / analizzare l'URL di riproduzione\n" +"Assicurati di avere diritto a questo contenuto e al tuo indirizzo IP (non geo-bloccato)\n" +"Prova ad accedere al contenuto con altri mezzi (app / sito Web ufficiale) per verificare se si tratta di un problema del componente aggiuntivo Kodi" + +msgctxt "#32057" +msgid "M3U8 file does not start with #EXTM3U" +msgstr "Il file M3U8 non inizia con #EXTM3U" + +msgctxt "#32058" +msgid "{version} (INSTALLED)" +msgstr "{version} (Installata)" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "Larghezza di banda massima (Mbit/s)" + +msgctxt "#32060" +msgid "Lowest" +msgstr "Minore" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "Qualità di riproduzione" + +msgctxt "#32062" +msgid "Inputstream HLS required to watch streams From Live.\n" +"Check HLS is enabled in settings and Inputstream Adaptive is installed" +msgstr "Inputstream HLS è richiesto per guardare i flussi Live.\n" +"Controlla che sia installato Inputstream Adaptive e che HLS sia abilitato nelle impostazioni" + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "Azione dal vivo" + +msgctxt "#32064" +msgid "From Start" +msgstr "Dall'inizio" + +msgctxt "#32065" +msgid "From Live" +msgstr "Dal vivo" + +msgctxt "#32066" +msgid "Ask" +msgstr "Chiedi" + +msgctxt "#32067" +msgid "Play From?" +msgstr "Riproduci da?" + +msgctxt "#32068" +msgid "{bandwidth:.2f} Mbit/s {resolution} {fps}" +msgstr "{bandwidth:.2f} Mbit/s {resolution} {fps}" + +msgctxt "#32069" +msgid "{fps:.2f} FPS" +msgstr "{fps:.2f} FPS" + +msgctxt "#32070" +msgid "Available Widevine CDM Versions" +msgstr "Versioni disponibili di Widevine CDM" + +msgctxt "#32071" +msgid "Unknown {version} (INSTALLLED)" +msgstr "Sconosciuta {version} (Installata)" + +msgctxt "#32072" +msgid "Widevine CDM ({version}) Installed\n" +"If content does not play, you may need to install the latest CDM version instead." +msgstr "Installata Widevine CDM ({version})\n" +"Se il contenuto non viene riprodotto, potrebbe essere necessario installare la versione CDM più aggiornata" + +msgctxt "#32073" +msgid "Disabled" +msgstr "Disabilitata" + +msgctxt "#32074" +msgid "URL returned error code: {code}" +msgstr "L'URL ha restituito un codice di errore: {code}" + +msgctxt "#32075" +msgid "Widevine CDM is built-in to Android\n" +"If content does not play, your Android device may not have a suitable Widevine level." +msgstr "Widevine CDM è integrato in Android\n" +"Se il contenuto non viene riprodotto, il tuo dispositivo Android potrebbe non avere un livello Widevine adatto" + +msgctxt "#32076" +msgid "Use Inputstream HLS for Live Streams" +msgstr "Usa Inputstream HLS per streaming live" + +msgctxt "#32077" +msgid "Your IP address is not allowed to access this content (geo-blocked)\n" +"You may need to use a VPN / Smart DNS. Sometimes even these can be blocked.\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "Il tuo indirizzo IP non è autorizzato ad accedere a questo contenuto (geo-bloccato)\n" +"Potrebbe essere necessario utilizzare una VPN / Smart DNS. A volte anche questi possono essere bloccati.\n" +"Prova ad accedere al contenuto con altri mezzi (app / sito Web ufficiale) per verificare se si tratta di un problema del componente aggiuntivo di Kodi" + +msgctxt "#32078" +msgid "Kiosk Mode" +msgstr "" + +msgctxt "#32079" +msgid "Setup IPTV Merge" +msgstr "" + +msgctxt "#32080" +msgid "EPG days to scrape" +msgstr "" + +msgctxt "#32081" +msgid "Live TV & EPG" +msgstr "" + +msgctxt "#32082" +msgid "Force built-in EPG scraper" +msgstr "" + +msgctxt "#32083" +msgid "Profile Activated" +msgstr "Profilo attivato" + +msgctxt "#32084" +msgid "Select Profile" +msgstr "Seleziona profilo" + +msgctxt "#32085" +msgid "This service requires a {required} location\n" +"We detected your location is {current}\n" +msgstr "" + +msgctxt "#32086" +msgid "Audio Allow List" +msgstr "" + +msgctxt "#32087" +msgid "Subtitle Allow List" +msgstr "" + +msgctxt "#32088" +msgid "Include Forced Subs" +msgstr "" + +msgctxt "#32089" +msgid "Include Non-forced Subs" +msgstr "" + +msgctxt "#32090" +msgid "Include Audio Description Tracks" +msgstr "" + +msgctxt "#32091" +msgid "Add Profile" +msgstr "Aggiungi un profilo" + +msgctxt "#32092" +msgid "Delete Profile" +msgstr "Elimina un profilo" + +msgctxt "#32093" +msgid "Random Pick" +msgstr "Scelta casuale" + +msgctxt "#32094" +msgid "Select Avatar" +msgstr "Seleziona Avatar" + +msgctxt "#32095" +msgid "Select profile to delete" +msgstr "Seleziona il profilo da eliminare" + +msgctxt "#32096" +msgid "Profile history, watchlist, and activity will be deleted. There is no way to undo this." +msgstr "La cronologia del profilo, l'elenco di controllo e l'attività verranno eliminati. Non c'è modo di annullare questo" + +msgctxt "#32097" +msgid "Delete {name}'s profile?" +msgstr "Elimina il profilo {name}" + +msgctxt "#32098" +msgid "Profile Deleted" +msgstr "Profilo eliminato" + +msgctxt "#32099" +msgid "{label} (Used)" +msgstr "{label} (Used)" + +msgctxt "#32100" +msgid "Kid friendly interface with only content suitable for kids." +msgstr "Interfaccia e contenuti adatti ai bambini" + +msgctxt "#32101" +msgid "Kids Profile?" +msgstr "Profilo per bambini" + +msgctxt "#32102" +msgid "Profile Name" +msgstr "Nome del profilo" + +msgctxt "#32103" +msgid "{name} is already being used" +msgstr "{name} è già in uso" + +msgctxt "#32104" +msgid "Disable profile switching in Kids profile" +msgstr "Disabilita il cambio profilo nel profilo Bambini" + +msgctxt "#32105" +msgid "Content requires a newer version of Inputstream Adaptive\n" +"Minimum required version: [B]{required}[/B]\n" +"Your current version: {current}" +msgstr "" + +msgctxt "#32106" +msgid "ARMv6 does not support Widevine playback" +msgstr "" + +msgctxt "#32107" +msgid "iOS does not support Widevine playback on Kodi" +msgstr "" + +msgctxt "#32108" +msgid "Next Page ({page})" +msgstr "Prossima pagina ({page})" \ No newline at end of file diff --git a/script.module.slyguy/resources/language/resource.language.nb_no/strings.po b/script.module.slyguy/resources/language/resource.language.nb_no/strings.po new file mode 100644 index 00000000..e628902e --- /dev/null +++ b/script.module.slyguy/resources/language/resource.language.nb_no/strings.po @@ -0,0 +1,496 @@ +msgid "" +msgstr "" + +msgctxt "#30001" +msgid "SlyGuy News" +msgstr "SlyGuy-nyheter" + +## COMMON ## + +msgctxt "#32000" +msgid "You need to login to access this content" +msgstr "Du må logge inn for å få tilgang til dette innholdet" + +msgctxt "#32001" +msgid "No default route found" +msgstr "Ingen default-rute funnet" + +msgctxt "#32002" +msgid "Are you sure you want to reset this add-on?\n" +"This will reset all settings and clear all data" +msgstr "Er du sikker på at du vil nullstille dette Kodi-tillegget?\n" +"Alle innstillinger og data vil bli resatt." + +msgctxt "#32003" +msgid "Reset Complete" +msgstr "Nullstilling utført" + +msgctxt "#32004" +msgid "Removed {delete_count} Cached Item" +msgstr "Slettet {delete_count} hurtiglagret innhold" + +msgctxt "#32005" +msgid "Clear Cache" +msgstr "Slett hurtiglager" + +msgctxt "#32006" +msgid "This add-on path is no longer valid\n" +"If your using favorites, remove the favorite and then re-create it via the add-on" +msgstr "Denne stien i Kodi-tillegget er ikke lenger gyldig.\n" +"Hvis du bruker favoritter: Slett favoritten og legg den til igjen via dette Kodi-tillegget." + +msgctxt "#32007" +msgid "Could not find a url for function: {function_name}" +msgstr "Kunne ikke finne URL for funksjonen {function_name}" + +msgctxt "#32008" +msgid "{addon_id} add-on is required" +msgstr "Kodi-tillegget {addon_id} på påkrevd" + +msgctxt "#32009" +msgid "Kodi Microsoft UWP version does not support Widevine playback.\n" +"If using Windows, use the .exe installer version from the Kodi website instead." +msgstr "Kodi for Microsoft UWP støtter ikke Widevine-avspilling.\n" +"Hvis du Windows, bruk exe-installeringsversjonen fra Kodi-nettsiden istedet." + +msgctxt "#32010" +msgid "Kodi 18 or higher required for Widevine playback on {system}" +msgstr "Kodi 18 eller høyere er påkrevd for Widevine-avspilling på {system}." + +msgctxt "#32011" +msgid "ARM 64bit does not support Widevine playback\n" +"An OS with a 32-bit userspace is required.\n" +"Try coreelec.org" +msgstr "ARM 64-bit støtter ikke Widevine-avspilling.\n" +"Et operativsystem med 32-bit userspace er påkrevd.\n" +"Prøv coreelec.org" + +msgctxt "#32012" +msgid "This system ({system}{arch} Kodi v{kodi_version}) does not yet support Widevine playback" +msgstr "Dette systemet ({system}{arch} Kodi v{kodi_version}) støtter ikke Widevine-avspilling ennå." + +msgctxt "#32013" +msgid "Could not find a media source from Brightcove\n" +"Server Message: {error}" +msgstr "Kunne ikke finne en mediakilde fra Brightcove.\n" +"Feilmelding: {error}" + +msgctxt "#32014" +msgid "Downloading required file\n" +"{url}" +msgstr "Laster ned påkrevd fil\n" +"{url}" + +msgctxt "#32015" +msgid "Widevine CDM" +msgstr "Widevine CDM" + +msgctxt "#32016" +msgid "There was an unexpected error installing the Widevine CDM\n" +"Please restart Kodi and try again" +msgstr "En uventet feil oppstod ved installering av Widevine CDM.\n" +"Vennligst restart Kodi og prøv igjen." + +msgctxt "#32017" +msgid "Use Cache" +msgstr "Bruk hurtiglager" + +msgctxt "#32018" +msgid "Inputstream Settings" +msgstr "Inputstream-innstillinger" + +msgctxt "#32019" +msgid "Reset Add-on" +msgstr "Nullstill Kodi-tillegget" + +msgctxt "#32020" +msgid "{addon} - Error" +msgstr "{addon} - Error" + +msgctxt "#32021" +msgid "Install Widevine CDM" +msgstr "Installér Widevine CDM" + +msgctxt "#32022" +msgid "Widevine CDM ({version}) Installed OK\n" +"If videos do not play, please restart Kodi and try again" +msgstr "Widevine CDM ({version}) installert OK.\n" +"Hvis videoavspilling ikke fungerer, prøv å restart Kodi." + +msgctxt "#32023" +msgid "Use Inputstream HLS for VOD Streams" +msgstr "Bruk Inputstream HLS for VOD (Video On Demand)" + +msgctxt "#32024" +msgid "Login" +msgstr "Logg inn" + +msgctxt "#32025" +msgid "Logout" +msgstr "Logg ut" + +msgctxt "#32026" +msgid "Settings" +msgstr "Innstillinger" + +msgctxt "#32027" +msgid "Are you sure you want to logout?" +msgstr "Er du sikker på at du vil logge ut?" + +msgctxt "#32028" +msgid "Failed to login.\n" +"Check your details are correct and try again." +msgstr ""Innlogging feilet.\n" +"Sjekk dine innloggingsdetaljer, og prøv igjen." + +msgctxt "#32029" +msgid "Search" +msgstr "Søk" + +msgctxt "#32030" +msgid "Search: {query}" +msgstr "Søk: {query}" + +msgctxt "#32031" +msgid "No Results" +msgstr "Ingen søketreff" + +msgctxt "#32032" +msgid "{addon} ({version}) - Unexpected Error" +msgstr "{addon} ({version}) - Uventet feil" + +msgctxt "#32033" +msgid "Failed to download required file: {filename}" +msgstr "Feilet med å laste ned påkrevd fil: {filename}" + +msgctxt "#32034" +msgid "General" +msgstr "Generelt" + +msgctxt "#32035" +msgid "Playback" +msgstr "Avspilling" + +msgctxt "#32036" +msgid "Advanced" +msgstr "Avansert" + +msgctxt "#32037" +msgid "Verify SSL Certs" +msgstr "Verifiser SSL-sertifikater" + +msgctxt "#32038" +msgid "Select Widevine CDM version to install" +msgstr "Velg Widevine CDM-versjon for installasjon" + +msgctxt "#32039" +msgid "Service Start Delay (0 = Random 10s-60s)" +msgstr "Oppstartsforsinkelse på service (0 = tilfeldig 10s-60s)" + +msgctxt "#32040" +msgid "MD5 checksum failed for file: {filename}\n" +"{local_md5} != {remote_md5}" +msgstr "MD5-sjekksum feilet for fil: {filename}\n" +"'{local_md5}' versus '{remote_md5}'" + +msgctxt "#32041" +msgid "No Items" +msgstr "Ingen data" + +msgctxt "#32042" +msgid "New add-on not found.\n" +"Update repos then try again." +msgstr "Nytt Kodi-programtillegg ikke funnet.\n" +"Oppdatér repo og prøv igjen." + +msgctxt "#32043" +msgid "Best" +msgstr "Best" + +msgctxt "#32044" +msgid "HTTP Timeout (seconds)" +msgstr "HTTP-timeout (sekunder)" + +msgctxt "#32045" +msgid "HTTP Retries" +msgstr "HTTP reforsøk" + +msgctxt "#32046" +msgid "Chunksize" +msgstr "Bufferstørrelse" + +msgctxt "#32047" +msgid "{label} (LATEST)" +msgstr "{label} (NYESTE)" + +msgctxt "#32048" +msgid "Bypass" +msgstr "Bypass" + +msgctxt "#32049" +msgid "\"{choose}\" item not found that matches pattern \"{pattern}\"" +msgstr "\"{choose}\" ble ikke funnet som matcher mønsteret \"{pattern}\"" + +msgctxt "#32050" +msgid "This add-on has moved\n" +"A new add-on '{new_addon_id}' is therefore required\n" +"Install and migrate to new add-on?" +msgstr "Dette Kodi-tillegget har flyttet.\n" +"Et nytt tillegg '{new_addon_id}' er derfor påkrevd.\n" +"Installere og migrere til det nye tillegget?" + +msgctxt "#32051" +msgid "Add-on has been migrated\n" +"Remove the old add-on?" +msgstr "Migrering utført.\n" +"Fjerne det gamle Kodi-tillegget?" + +msgctxt "#32052" +msgid "Unknown Error" +msgstr "Ukjent feil" + +msgctxt "#32053" +msgid "This manifest contains multiple base urls\n" +"Support for this will hopefully be introduced in Kodi 19\n" +"There is a chance this content may not play" +msgstr "Denne manifest-filen inneholder flere BaseURL'er.\n" +"Støtte for dette vil forhåpentligvis bli introdusert i Kodi 19.\n" +"Det kan hende avspillingen feiler." + +msgctxt "#32054" +msgid "Custom" +msgstr "Tilpasset" + +msgctxt "#32055" +msgid "Ask" +msgstr "Spør" + +msgctxt "#32056" +msgid "Failed to fetch / parse playback URL\n" +"Make sure your entitled to this content and your IP address is allowed (not geo-blocked)\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "Feilet med fetche / parse avspillings-URL.\n" +"Verifiser at du har tilgang til innholdet, og at din IP-adresse har tilatelse (ikke er geo-blokkert).\n" +"Sjekk om du har tilgang på andre plattformer (f.eks. på nettsiden) for å sjekke om det er et Kodi-problem." + +msgctxt "#32057" +msgid "M3U8 file does not start with #EXTM3U" +msgstr "M3U8-filen starter ikke med #EXTM3U" + +msgctxt "#32058" +msgid "{version} (INSTALLED)" +msgstr "{version} (INNSTALLERT)" + +msgctxt "#32059" +msgid "Max Bandwidth (Mbit/s)" +msgstr "Maks båndbredde (Mbit/s)" + +msgctxt "#32060" +msgid "Lowest" +msgstr "Dårligst" + +msgctxt "#32061" +msgid "Playback Quality" +msgstr "Avspillingskvalitet" + +msgctxt "#32062" +msgid "Inputstream HLS required to watch streams From Live.\n" +"Check HLS is enabled in settings and Inputstream Adaptive is installed" +msgstr "Inputstream HLS påkrevd for å se direktestrømmer.\n" +"Sjekk om HLS er aktivert i innstillinger, og at Inputstream Adaptive er installert." + +msgctxt "#32063" +msgid "Live Play Action" +msgstr "" + +msgctxt "#32064" +msgid "From Start" +msgstr "Fra begynnelsen" + +msgctxt "#32065" +msgid "From Live" +msgstr "Fra direkte" + +msgctxt "#32066" +msgid "Ask" +msgstr "Spør" + +msgctxt "#32067" +msgid "Play From?" +msgstr "Spill av fra?" + +msgctxt "#32068" +msgid "{bandwidth:.2f} Mbit/s {resolution} {fps}" +msgstr "{bandwidth:.2f} Mbit/s {resolution} {fps}" + +msgctxt "#32069" +msgid "{fps:.2f} FPS" +msgstr "{fps:.2f} bilder pr sekund" + +msgctxt "#32070" +msgid "Available Widevine CDM Versions" +msgstr "Tilgjengelige Widevine CDM-versjoner" + +msgctxt "#32071" +msgid "Unknown {version} (INSTALLLED)" +msgstr "Ukjent {version} (INSTALLLED)" + +msgctxt "#32072" +msgid "Widevine CDM ({version}) Installed\n" +"If content does not play, you may need to install the latest CDM version instead." +msgstr "Widevine CDM ({version}) installert.\n" +"Hvis avspilling feiler må du kanskje innstallere den nyeste versjonen av CDM istedet." + +msgctxt "#32073" +msgid "Disabled" +msgstr "Deaktivert" + +msgctxt "#32074" +msgid "URL returned error code: {code}" +msgstr "URL returnerte feilkode: {code}" + +msgctxt "#32075" +msgid "Widevine CDM is built-in to Android\n" +"If content does not play, your Android device may not have a suitable Widevine level." +msgstr "Widevine CDM er innebygget i Android.\n" +"Hvis avspilling feiler kan det hende at Android-enheten din ikke har tilstrekkelig Widevine-nivå." + +msgctxt "#32076" +msgid "Use Inputstream HLS for Live Streams" +msgstr "Bruk Inputstream HLS for direktestrømmer" + +msgctxt "#32077" +msgid "Your IP address is not allowed to access this content (geo-blocked)\n" +"You may need to use a VPN / Smart DNS. Sometimes even these can be blocked.\n" +"Try accessing the content via other means (offical app / website) to test if it's a Kodi add-on issue" +msgstr "Din IP-adresse har ikke tilgang til dette innholdet (geo-blokkert).\n" +"Kan hende du trenger å bruke en VPN / Smart DNS. Noen ganger kan selv disse bli blokkert.\n" +"Sjekk om du har tilgang på andre plattformer (f.eks. på nettsiden) for å sjekke om det er et Kodi-problem." + +msgctxt "#32078" +msgid "Kiosk Mode" +msgstr "Kiosk-modus" + +msgctxt "#32079" +msgid "Setup IPTV Merge" +msgstr "" + +msgctxt "#32080" +msgid "EPG Days to Generate" +msgstr "" + +msgctxt "#32081" +msgid "Live TV & EPG" +msgstr "" + +msgctxt "#32082" +msgid "Force built-in EPG scraper" +msgstr "" + +msgctxt "#32083" +msgid "Profile Activated" +msgstr "Profil aktivert" + +msgctxt "#32084" +msgid "Select Profile" +msgstr "Velg profil" + +msgctxt "#32085" +msgid "This service requires a {required} location\n" +"We detected your location is {current}\n" +msgstr "Denne tjenesten krever følgende lokasjon: {required}\n" +"Din nåværende lokasjon: {current}\n" + +msgctxt "#32086" +msgid "Audio Allow List" +msgstr "Lydspor-filter (kommaseparert)" + +msgctxt "#32087" +msgid "Subtitle Allow List" +msgstr "Undertekst-filter (kommaseparert)" + +msgctxt "#32088" +msgid "Include Forced Subs" +msgstr "Inkluder tvungne undertekster" + +msgctxt "#32089" +msgid "Include Non-forced Subs" +msgstr "Inkluder vanlige undertekster" + +msgctxt "#32090" +msgid "Include Audio Description Tracks" +msgstr "Inkluder lydspor for synstolkning" + +msgctxt "#32091" +msgid "Add Profile" +msgstr "Legg til profil" + +msgctxt "#32092" +msgid "Delete Profile" +msgstr "Slett en profil" + +msgctxt "#32093" +msgid "Random Pick" +msgstr "Tilfeldig valg" + +msgctxt "#32094" +msgid "Select Avatar" +msgstr "Velg profilbilde" + +msgctxt "#32095" +msgid "Select profile to delete" +msgstr "Velg profilen som skal slettes" + +msgctxt "#32096" +msgid "Profile history, watchlist, and activity will be deleted. There is no way to undo this." +msgstr "Profil-historikk og favoritter vil bli slettet. Dette kan ikke omgjøres." + +msgctxt "#32097" +msgid "Delete {name}'s profile?" +msgstr "Slette profilen {name}?" + +msgctxt "#32098" +msgid "Profile Deleted" +msgstr "Profil slettet" + +msgctxt "#32099" +msgid "{label} (Used)" +msgstr "{label} (allerede tatt)" + +msgctxt "#32100" +msgid "Kid friendly interface with only content suitable for kids." +msgstr "Barnevennlig grensesnitt med kun innhold passende for barn." + +msgctxt "#32101" +msgid "Kids Profile?" +msgstr "Barneprofil?" + +msgctxt "#32102" +msgid "Profile Name" +msgstr "Profilnavn" + +msgctxt "#32103" +msgid "{name} is already being used" +msgstr "{name} er allerede i bruk" + +msgctxt "#32104" +msgid "Disable profile switching in Kids profile" +msgstr "Deaktiver bytte av profil i barneprofil" + +msgctxt "#32105" +msgid "Content requires a newer version of Inputstream Adaptive\n" +"Minimum required version: [B]{required}[/B]\n" +"Your current version: {current}" +msgstr "" + +msgctxt "#32106" +msgid "ARMv6 does not support Widevine playback" +msgstr "" + +msgctxt "#32107" +msgid "iOS does not support Widevine playback on Kodi" +msgstr "" + +msgctxt "#32108" +msgid "Next Page ({page})" +msgstr "Neste side ({page})" \ No newline at end of file diff --git a/script.module.slyguy/resources/lib/__init__.py b/script.module.slyguy/resources/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/script.module.slyguy/resources/lib/constants.py b/script.module.slyguy/resources/lib/constants.py new file mode 100644 index 00000000..f9a76f17 --- /dev/null +++ b/script.module.slyguy/resources/lib/constants.py @@ -0,0 +1,8 @@ +## NEWS ## +NEWS_URL = 'https://k.slyguy.xyz/.repo/news.json.gz' +ADDONS_URL = 'https://k.slyguy.xyz/.repo/addons.json.gz' +ADDONS_MD5 = 'https://k.slyguy.xyz/.repo/addons.xml.md5' +NEWS_CHECK_TIME = 7200 #2 Hours +UPDATES_CHECK_TIME = 3600 #1 Hour +NEWS_MAX_TIME = 432000 #5 Days +SERVICE_BUILD_TIME = 3600 #1 Hour \ No newline at end of file diff --git a/script.module.slyguy/resources/lib/default.py b/script.module.slyguy/resources/lib/default.py new file mode 100644 index 00000000..43b748a1 --- /dev/null +++ b/script.module.slyguy/resources/lib/default.py @@ -0,0 +1,4 @@ +from slyguy import settings + +def run(): + settings.open() \ No newline at end of file diff --git a/script.module.slyguy/resources/lib/language.py b/script.module.slyguy/resources/lib/language.py new file mode 100644 index 00000000..9b20ddd8 --- /dev/null +++ b/script.module.slyguy/resources/lib/language.py @@ -0,0 +1,6 @@ +from slyguy.language import BaseLanguage + +class Language(BaseLanguage): + NEWS_HEADING = 30001 + +_ = Language() \ No newline at end of file diff --git a/script.module.slyguy/resources/lib/monitor.py b/script.module.slyguy/resources/lib/monitor.py new file mode 100644 index 00000000..3e22fed8 --- /dev/null +++ b/script.module.slyguy/resources/lib/monitor.py @@ -0,0 +1,6 @@ +from kodi_six import xbmc + +class Monitor(xbmc.Monitor): + pass + +monitor = Monitor() \ No newline at end of file diff --git a/script.module.slyguy/resources/lib/player.py b/script.module.slyguy/resources/lib/player.py new file mode 100644 index 00000000..21d6dc8e --- /dev/null +++ b/script.module.slyguy/resources/lib/player.py @@ -0,0 +1,97 @@ +import json +import time + +from kodi_six import xbmc +from threading import Thread + +from slyguy import userdata, inputstream +from slyguy.util import get_kodi_string, set_kodi_string +from slyguy.log import log + +from .monitor import monitor + +class Player(xbmc.Player): + # def __init__(self, *args, **kwargs): + # self._thread = None + # self._up_next = None + # self._callback = None + # super(Player, self).__init__(*args, **kwargs) + + def playback(self, playing_file): + last_callback = None + cur_time = time.time() + play_time = 0 + + while not monitor.waitForAbort(1) and self.isPlaying() and self.getPlayingFile() == playing_file: + cur_time = time.time() + play_time = self.getTime() + + if self._up_next and play_time >= self._up_next['time']: + play_time = self.getTotalTime() + self.seekTime(play_time) + last_callback = None + + if self._callback and self._callback['type'] == 'interval' and (not last_callback or cur_time >= last_callback + self._callback['interval']): + callback = self._callback['callback'].replace('%24playback_time', str(int(play_time))).replace('$playback_time', str(int(play_time))) + xbmc.executebuiltin('RunPlugin({})'.format(callback)) + last_callback = cur_time + + if self._callback: + callback = self._callback['callback'].replace('%24playback_time', str(int(play_time))).replace('$playback_time', str(int(play_time))) + xbmc.executebuiltin('RunPlugin({})'.format(callback)) + + def onAVStarted(self): + self._up_next = None + self._callback = None + self._playlist = None + + if self.isPlayingVideo(): + self._playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + else: + self._playlist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + + up_next = get_kodi_string('_slyguy_play_next') + if up_next: + set_kodi_string('_slyguy_play_next') + up_next = json.loads(up_next) + if up_next['playing_file'] == self.getPlayingFile(): + if up_next['next_file']: + self._playlist.remove(up_next['next_file']) + self._playlist.add(up_next['next_file'], index=self._playlist.getposition()+1) + + if up_next['time']: + self._up_next = up_next + + callback = get_kodi_string('_slyguy_play_callback') + if callback: + set_kodi_string('_slyguy_play_callback') + callback = json.loads(callback) + if callback['playing_file'] == self.getPlayingFile() and callback['callback']: + self._callback = callback + + if self._up_next or self._callback: + self._thread = Thread(target=self.playback, args=(self.getPlayingFile(),)) + self._thread.start() + + # def onPlayBackEnded(self): + # vid_playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + # music_playlist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + # position = vid_playlist.getposition()+1 + + # if (vid_playlist.size() <= 1 or vid_playlist.size() == position) and (music_playlist.size() <= 1 or music_playlist.size() == position): + # self.onPlayBackStopped() + + # def onPlayBackStopped(self): + # set_kodi_string('_slyguy_last_quality') + + # def onPlayBackStarted(self): + # pass + + # def onPlayBackPaused(self): + # print("AV PAUSED") + + # def onPlayBackResumed(self): + # print("AV RESUME") + + # def onPlayBackError(self): + # self.onPlayBackStopped() \ No newline at end of file diff --git a/script.module.slyguy/resources/lib/proxy.py b/script.module.slyguy/resources/lib/proxy.py new file mode 100644 index 00000000..586267eb --- /dev/null +++ b/script.module.slyguy/resources/lib/proxy.py @@ -0,0 +1,895 @@ +import threading +import os +import shutil +import re +import uuid +import time +import base64 +import json + +from io import BytesIO +from xml.dom.minidom import parseString +from collections import defaultdict +from functools import cmp_to_key + +from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer +from six.moves.socketserver import ThreadingMixIn +from six.moves.urllib.parse import urlparse, urljoin, unquote, parse_qsl, quote_plus +from kodi_six import xbmc, xbmcvfs +from requests import ConnectionError + +from slyguy.log import log +from slyguy.constants import * +from slyguy.util import check_port, remove_file, get_kodi_string, set_kodi_string, fix_url +from slyguy.exceptions import Exit +from slyguy import settings, gui +from slyguy.session import RawSession +from slyguy.language import _ +from slyguy.router import add_url_args + +from .constants import * + +#ADDON_DEV = True + +REMOVE_IN_HEADERS = ['upgrade', 'host'] +REMOVE_OUT_HEADERS = ['date', 'server', 'transfer-encoding'] + +PROXY_PORT = check_port(PROXY_PORT) +if not PROXY_PORT: + PROXY_PORT = check_port() + +PROXY_PATH = 'http://{}:{}/'.format(PROXY_HOST, PROXY_PORT) +settings.set('proxy_path', PROXY_PATH) + +CODECS = { + 'avc': 'H.264', + 'hvc': 'H.265', + 'hev': 'H.265', + 'mp4v': 'MPEG-4', + 'mp4s': 'MPEG-4', + 'dvh': 'H.265 DV', +} + +CODEC_RANKING = ['MPEG-4', 'H.264', 'H.265', 'H.265 DV'] + +PROXY_GLOBAL = { + 'last_quality': QUALITY_BEST, + 'session': {}, +} + +class RequestHandler(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def __init__(self, request, client_address, server): + try: + BaseHTTPRequestHandler.__init__(self, request, client_address, server) + except (IOError, OSError) as e: + pass + + def log_message(self, format, *args): + return + + def setup(self): + BaseHTTPRequestHandler.setup(self) + self.request.settimeout(5) + + def _get_url(self): + url = self.path.lstrip('/').strip('\\') + + self._headers = {} + for header in self.headers: + if header.lower() not in REMOVE_IN_HEADERS: + self._headers[header.lower()] = self.headers[header] + + self._headers['accept-encoding'] = 'gzip, deflate, br' + + length = int(self._headers.get('content-length', 0)) + self._post_data = self.rfile.read(length) if length else None + + self._session = PROXY_GLOBAL['session'] + + try: + proxy_data = json.loads(get_kodi_string('_slyguy_quality')) + if self._session.get('session_id') != proxy_data['session_id']: + self._session = {} + + self._session.update(proxy_data) + set_kodi_string('_slyguy_quality', '') + except: + pass + + PROXY_GLOBAL['session'] = self._session + + if url.lower().startswith('plugin'): + try: + url = self._plugin_request(url) + except Exception as e: + log.debug('Plugin requsted failed') + log.exception(e) + + return url + + def _plugin_request(self, url): + data_path = xbmc.translatePath('special://temp/proxy.post') + with open(data_path, 'wb') as f: + f.write(self._post_data or b'') + + url = add_url_args(url, _data_path=data_path, _headers=json.dumps(self._headers)) + + log.debug('PLUGIN REQUEST: {}'.format(url)) + dirs, files = xbmcvfs.listdir(url) + + path = unquote(files[0]) + split = path.split('|') + url = split[0] + + if len(split) > 1: + headers = dict(parse_qsl(u'{}'.format(split[1]), keep_blank_values=True)) + self._headers.update(headers) + + return url + + def _manifest_middleware(self, data): + url = self._session.get('manifest_middleware') + if not url: + return data + + data_path = xbmc.translatePath('special://temp/proxy.manifest') + with open(data_path, 'wb') as f: + f.write(data.encode('utf8')) + + url = add_url_args(url, _data_path=data_path, _headers=json.dumps(self._headers)) + + log.debug('PLUGIN MANIFEST MIDDLEWARE REQUEST: {}'.format(url)) + dirs, files = xbmcvfs.listdir(url) + + path = unquote(files[0]) + split = path.split('|') + data_path = split[0] + + if len(split) > 1: + headers = dict(parse_qsl(u'{}'.format(split[1]), keep_blank_values=True)) + self._headers.update(headers) + + with open(data_path, 'rb') as f: + data = f.read().decode('utf8') + + remove_file(data_path) + + return data + + def do_GET(self): + url = self._get_url() + log.debug('GET IN: {}'.format(url)) + + if not url: + response = Response() + response.headers = {} + response.status_code = 404 + response.stream = ResponseStream(response) + response.stream.content = b'' + self._output_response(response) + return + + response = self._proxy_request('GET', url) + + if self._session.get('redirecting') or not self._session.get('type') or not self._session.get('manifest'): + self._output_response(response) + return + + parse = urlparse(self.path.lower()) + + try: + if self._session.get('type') == 'm3u8' and (url == self._session['manifest'] or parse.path.endswith('.m3u') or parse.path.endswith('.m3u8')): + self._parse_m3u8(response) + + elif self._session.get('type') == 'mpd' and url == self._session['manifest']: + self._parse_dash(response) + self._session['manifest'] = None # unset manifest url so isn't parsed again + except Exception as e: + if type(e) != Exit and url == self._session['manifest']: + gui.error(_.QUALITY_PARSE_ERROR) + + response.status_code = 500 + response.stream.content = str(e).encode('utf-8') + + self._output_response(response) + + def _quality_select(self, qualities): + def codec_rank(_codecs): + highest = -1 + + for codec in _codecs: + for key in CODECS: + if codec.lower().startswith(key.lower()) and CODECS[key] in CODEC_RANKING: + rank = CODEC_RANKING.index(CODECS[key]) + if not highest or rank > highest: + highest = rank + + return highest + + def compare(a, b): + if a['resolution'] and b['resolution']: + if int(a['resolution'].split('x')[0]) > int(b['resolution'].split('x')[0]): + return 1 + elif int(a['resolution'].split('x')[0]) < int(b['resolution'].split('x')[0]): + return -1 + + # Same resolution - compare codecs + a_rank = codec_rank(a['codecs']) + b_rank = codec_rank(b['codecs']) + + if a_rank > b_rank: + return 1 + elif a_rank < b_rank: + return -1 + + # Same codec rank - compare bandwidth + if a['bandwidth'] and b['bandwidth']: + if a['bandwidth'] > b['bandwidth']: + return 1 + elif a['bandwidth'] < b['bandwidth']: + return -1 + + # Same bandwidth - they are equal (could compare framerate) + return 0 + + def _stream_label(stream): + try: fps = _(_.QUALITY_FPS, fps=float(stream['frame_rate'])) + except: fps = '' + + codec_string = '' + for codec in stream['codecs']: + for key in CODECS: + if codec.lower().startswith(key.lower()): + codec_string += ' ' + CODECS[key] + + return _(_.QUALITY_BITRATE, bandwidth=int((stream['bandwidth']/10000.0))/100.00, resolution=stream['resolution'], fps=fps, codecs=codec_string.strip()).replace(' ', ' ') + + if self._session.get('selected_quality') is not None: + if self._session['selected_quality'] in (QUALITY_DISABLED, QUALITY_SKIP): + return None + else: + return qualities[self._session['selected_quality']] + + quality_compare = cmp_to_key(compare) + + quality = int(self._session.get('quality', QUALITY_ASK)) + streams = sorted(qualities, key=quality_compare, reverse=True) + + if not streams: + quality = QUALITY_DISABLED + elif len(streams) < 2: + quality = QUALITY_BEST + + if quality == QUALITY_ASK: + options = [] + options.append([QUALITY_BEST, _.QUALITY_BEST]) + + for x in streams: + options.append([x, _stream_label(x)]) + + options.append([QUALITY_LOWEST, _.QUALITY_LOWEST]) + options.append([QUALITY_SKIP, _.QUALITY_SKIP]) + + values = [x[0] for x in options] + labels = [x[1] for x in options] + + default = -1 + if PROXY_GLOBAL['last_quality'] in values: + default = values.index(PROXY_GLOBAL['last_quality']) + else: + options = [streams[-1]] + for stream in streams: + if PROXY_GLOBAL['last_quality'] >= stream['bandwidth']: + options.append(stream) + + default = values.index(sorted(options, key=quality_compare, reverse=True)[0]) + + index = gui.select(_.PLAYBACK_QUALITY, labels, preselect=default, autoclose=5000) + if index < 0: + raise Exit('Cancelled quality select') + + quality = values[index] + if index != default: + PROXY_GLOBAL['last_quality'] = quality['bandwidth'] if quality in qualities else quality + set_kodi_string('_slyguy_last_quality', PROXY_GLOBAL['last_quality']) + + if quality in (QUALITY_DISABLED, QUALITY_SKIP): + quality = quality + elif quality == QUALITY_BEST: + quality = streams[0] + elif quality == QUALITY_LOWEST: + quality = streams[-1] + elif quality not in streams: + options = [streams[-1]] + for stream in streams: + if quality >= stream['bandwidth']: + options.append(stream) + quality = sorted(options, key=quality_compare, reverse=True)[0] + + if quality in qualities: + self._session['selected_quality'] = qualities.index(quality) + return qualities[self._session['selected_quality']] + else: + self._session['selected_quality'] = quality + return None + + def _parse_dash(self, response): + if ADDON_DEV: + root = parseString(response.stream.content) + mpd = root.toprettyxml(encoding='utf-8') + mpd = b"\n".join([ll.rstrip() for ll in mpd.splitlines() if ll.strip()]) + with open(xbmc.translatePath('special://temp/in.mpd'), 'wb') as f: + f.write(mpd) + + start = time.time() + + data = response.stream.content.decode('utf8') + data = self._manifest_middleware(data) + + ## SUPPORT NEW DOLBY FORMAT https://github.com/xbmc/inputstream.adaptive/pull/466 + data = data.replace('tag:dolby.com,2014:dash:audio_channel_configuration:2011', 'urn:dolby:dash:audio_channel_configuration:2011') + ## SUPPORT EC-3 CHANNEL COUNT https://github.com/xbmc/inputstream.adaptive/pull/618 + data = data.replace('urn:mpeg:mpegB:cicp:ChannelConfiguration', 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011') + + try: + root = parseString(data.encode('utf8')) + except Exception as e: + log.debug("Proxy: Failed to parse MPD") + log.exception(e) + return self._output_response(response) + + mpd = root.getElementsByTagName("MPD")[0] + + ## Live mpd needs non-last periods removed + ## https://github.com/xbmc/inputstream.adaptive/issues/574 + if 'type' in mpd.attributes.keys() and mpd.getAttribute('type').lower() == 'dynamic': + periods = [elem for elem in root.getElementsByTagName('Period')] + + # Keep last period + if len(periods) > 1: + periods.pop() + for elem in periods: + elem.parentNode.removeChild(elem) + ################################################# + + ## SORT ADAPTION SETS BY BITRATE ## + video_sets = [] + audio_sets = [] + lang_adap_sets = [] + streams, all_streams, ids = [], [], [] + adap_parent = None + + default_language = self._session.get('default_language', '') + for adap_set in root.getElementsByTagName('AdaptationSet'): + adap_parent = adap_set.parentNode + + highest_bandwidth = 0 + is_video = False + is_trick = False + + for stream in adap_set.getElementsByTagName("Representation"): + attribs = {} + + ## Make sure Representation are last in adaptionset + adap_set.removeChild(stream) + adap_set.appendChild(stream) + ####### + + for key in adap_set.attributes.keys(): + attribs[key] = adap_set.getAttribute(key) + + for key in stream.attributes.keys(): + attribs[key] = stream.getAttribute(key) + + if default_language and 'audio' in attribs.get('mimeType', '') and attribs.get('lang').lower() == default_language.lower() and adap_set not in lang_adap_sets: + lang_adap_sets.append(adap_set) + + bandwidth = 0 + if 'bandwidth' in attribs: + bandwidth = int(attribs['bandwidth']) + if bandwidth > highest_bandwidth: + highest_bandwidth = bandwidth + + if 'maxPlayoutRate' in attribs: + is_trick = True + + if 'video' in attribs.get('mimeType', '') and not is_trick: + is_video = True + + resolution = '' + if 'width' in attribs and 'height' in attribs: + resolution = '{}x{}'.format(attribs['width'], attribs['height']) + + frame_rate = '' + if 'frameRate'in attribs: + frame_rate = attribs['frameRate'] + try: + if '/' in str(frame_rate): + split = frame_rate.split('/') + frame_rate = float(split[0]) / float(split[1]) + except: + frame_rate = '' + + codecs = [x for x in attribs.get('codecs', '').split(',') if x] + stream = {'bandwidth': bandwidth, 'resolution': resolution, 'frame_rate': frame_rate, 'codecs': codecs, 'id': attribs['id'], 'elem': stream} + all_streams.append(stream) + + if stream['id'] not in ids: + streams.append(stream) + ids.append(stream['id']) + + adap_parent.removeChild(adap_set) + + if is_trick: + continue + + if is_video: + video_sets.append([highest_bandwidth, adap_set, adap_parent]) + else: + audio_sets.append([highest_bandwidth, adap_set, adap_parent]) + + video_sets.sort(key=lambda x: x[0], reverse=True) + audio_sets.sort(key=lambda x: x[0], reverse=True) + + for elem in video_sets: + elem[2].appendChild(elem[1]) + + for elem in audio_sets: + elem[2].appendChild(elem[1]) + + ## Set default languae + if lang_adap_sets: + for elem in root.getElementsByTagName('Role'): + if elem.getAttribute('schemeIdUri') == 'urn:mpeg:dash:role:2011': + elem.parentNode.removeChild(elem) + + for adap_set in lang_adap_sets: + elem = root.createElement('Role') + elem.setAttribute('schemeIdUri', 'urn:mpeg:dash:role:2011') + elem.setAttribute('value', 'main') + adap_set.appendChild(elem) + log.debug('default language set to: {}'.format(default_language)) + ############# + + ## Insert subtitles + if adap_parent: + for idx, subtitle in enumerate(self._session.get('subtitles') or []): + elem = root.createElement('AdaptationSet') + elem.setAttribute('mimeType', subtitle[0]) + elem.setAttribute('lang', subtitle[1]) + elem.setAttribute('id', 'caption_{}'.format(idx)) + + elem2 = root.createElement('Representation') + elem2.setAttribute('id', 'caption_rep_{}'.format(idx)) + + if 'ttml' in subtitle[0]: + elem2.setAttribute('codecs', 'ttml') + + elem3 = root.createElement('BaseURL') + elem4 = root.createTextNode(subtitle[2]) + + elem3.appendChild(elem4) + elem2.appendChild(elem3) + elem.appendChild(elem2) + + adap_parent.appendChild(elem) + ################## + + ## Convert BaseURLS + base_url_parents = [] + for elem in root.getElementsByTagName('BaseURL'): + url = elem.firstChild.nodeValue + + if elem.parentNode in base_url_parents: + log.debug('Non-1st BaseURL removed: {}'.format(url)) + elem.parentNode.removeChild(elem) + continue + + if '://' not in url: + url = urljoin(response.url, url) + + elem.firstChild.nodeValue = PROXY_PATH + url + base_url_parents.append(elem.parentNode) + ################ + + ## Convert to proxy paths + elems = root.getElementsByTagName('SegmentTemplate') + elems.extend(root.getElementsByTagName('SegmentURL')) + + for e in elems: + def process_attrib(attrib): + if attrib not in e.attributes.keys(): + return + + url = e.getAttribute(attrib) + if '://' in url: + e.setAttribute(attrib, PROXY_PATH + url) + + process_attrib('initialization') + process_attrib('media') + ############### + + ## Get selected quality + selected = self._quality_select(streams) + if selected: + for stream in all_streams: + if stream['id'] != selected['id']: + stream['elem'].parentNode.removeChild(stream['elem']) + ################# + + ## Remove empty adaption sets + for adap_set in root.getElementsByTagName('AdaptationSet'): + if not adap_set.getElementsByTagName('Representation'): + adap_set.parentNode.removeChild(adap_set) + ################# + + if ADDON_DEV: + mpd = root.toprettyxml(encoding='utf-8') + mpd = b"\n".join([ll.rstrip() for ll in mpd.splitlines() if ll.strip()]) + + log.debug('Time taken: {}'.format(time.time() - start)) + with open(xbmc.translatePath('special://temp/out.mpd'), 'wb') as f: + f.write(mpd) + else: + mpd = root.toxml(encoding='utf-8') + + response.stream.content = mpd + + def _parse_m3u8_master(self, m3u8, master_url): + def _process_media(line): + attribs = {} + + for key, value in re.findall('([\w-]+)="?([^",]*)[",$]?', line): + attribs[key.upper()] = value.strip() + + return attribs + + audio_whitelist = [x.strip().lower() for x in self._session.get('audio_whitelist', '').split(',') if x] + subs_whitelist = [x.strip().lower() for x in self._session.get('subs_whitelist', '').split(',') if x] + subs_forced = int(self._session.get('subs_forced', 1)) + subs_non_forced = int(self._session.get('subs_non_forced', 1)) + audio_description = int(self._session.get('audio_description', 1)) + original_language = self._session.get('original_language', '').lower().strip() + default_language = self._session.get('default_language', '').lower().strip() + + if original_language and not default_language: + default_language = original_language + + if audio_whitelist: + audio_whitelist.append(original_language) + audio_whitelist.append(default_language) + + def _lang_allowed(lang, lang_list): + for _lang in lang_list: + if not _lang: + continue + + if lang.startswith(_lang): + return True + + return False + + default_groups = [] + groups = defaultdict(list) + for line in m3u8.splitlines(): + if not line.startswith('#EXT-X-MEDIA'): + continue + + attribs = _process_media(line) + if not attribs: + continue + + if audio_whitelist and attribs.get('TYPE') == 'AUDIO' and 'LANGUAGE' in attribs and not _lang_allowed(attribs['LANGUAGE'].lower().strip(), audio_whitelist): + m3u8 = m3u8.replace(line, '') + continue + + if subs_whitelist and attribs.get('TYPE') == 'SUBTITLES' and 'LANGUAGE' in attribs and not _lang_allowed(attribs['LANGUAGE'].lower().strip(), subs_whitelist): + m3u8 = m3u8.replace(line, '') + continue + + if not subs_forced and attribs.get('TYPE') == 'SUBTITLES' and attribs.get('FORCED','').upper() == 'YES': + m3u8 = m3u8.replace(line, '') + continue + + if not subs_non_forced and attribs.get('TYPE') == 'SUBTITLES' and attribs.get('FORCED','').upper() != 'YES': + m3u8 = m3u8.replace(line, '') + continue + + if not audio_description and attribs.get('TYPE') == 'AUDIO' and attribs.get('CHARACTERISTICS','').lower() == 'public.accessibility.describes-video': + m3u8 = m3u8.replace(line, '') + continue + + groups[attribs['GROUP-ID']].append([attribs, line]) + if attribs.get('DEFAULT') == 'YES' and attribs['GROUP-ID'] not in default_groups: + default_groups.append(attribs['GROUP-ID']) + + if default_language: + for group_id in groups: + if group_id in default_groups: + continue + + languages = [] + for group in groups[group_id]: + attribs, line = group + + attribs['AUTOSELECT'] = 'NO' + attribs['DEFAULT'] = 'NO' + + if attribs['LANGUAGE'] not in languages or attribs.get('TYPE') == 'SUBTITLES': + attribs['AUTOSELECT'] = 'YES' + + if attribs['LANGUAGE'].lower().strip().startswith(default_language): + attribs['DEFAULT'] = 'YES' + + languages.append(attribs['LANGUAGE']) + + for group_id in groups: + for group in groups[group_id]: + attribs, line = group + + # FIX es-ES > es / fr-FR > fr languages # + if 'LANGUAGE' in attribs: + split = attribs['LANGUAGE'].split('-') + if len(split) > 1 and split[1].lower() == split[0].lower(): + attribs['LANGUAGE'] = split[0] + ############################# + + new_line = '#EXT-X-MEDIA:' if attribs else '' + for key in attribs: + new_line += u'{}="{}",'.format(key, attribs[key]) + + m3u8 = m3u8.replace(line, new_line.rstrip(',')) + + lines = list(m3u8.splitlines()) + line1 = None + streams, all_streams, urls, metas = [], [], [], [] + for index, line in enumerate(lines): + if not line.strip(): + continue + + if line.startswith('#EXT-X-STREAM-INF'): + line1 = index + elif line1 and not line.startswith('#'): + attribs = _process_media(lines[line1]) + + codecs = [x for x in attribs.get('CODECS', '').split(',') if x] + bandwidth = int(attribs.get('BANDWIDTH') or 0) + resolution = attribs.get('RESOLUTION', '') + frame_rate = attribs.get('FRAME_RATE', '') + + url = line + if '://' in url: + url = '/'+'/'.join(url.lower().split('://')[1].split('/')[1:]) + + stream = {'bandwidth': int(bandwidth), 'resolution': resolution, 'frame_rate': frame_rate, 'codecs': codecs, 'url': url, 'lines': [line1, index]} + all_streams.append(stream) + + if stream['url'] not in urls and lines[line1] not in metas: + streams.append(stream) + urls.append(stream['url']) + metas.append(lines[line1]) + + line1 = None + + selected = self._quality_select(streams) + if selected: + adjust = 0 + for stream in all_streams: + if stream['url'] != selected['url']: + for index in stream['lines']: + lines.pop(index-adjust) + adjust += 1 + + return '\n'.join(lines) + + def _parse_m3u8(self, response): + m3u8 = response.stream.content.decode('utf8') + + is_master = False + if '#EXTM3U' not in m3u8: + raise Exception('Invalid m3u8') + + if '#EXT-X-STREAM-INF' in m3u8: + is_master = True + file_name = 'master' + else: + file_name = 'sub' + + if ADDON_DEV: + start = time.time() + _m3u8 = m3u8.encode('utf8') + _m3u8 = b"\n".join([ll.rstrip() for ll in _m3u8.splitlines() if ll.strip()]) + with open(xbmc.translatePath('special://temp/'+file_name+'-in.m3u8'), 'wb') as f: + f.write(_m3u8) + + if is_master: + m3u8 = self._manifest_middleware(m3u8) + m3u8 = self._parse_m3u8_master(m3u8, response.url) + + base_url = urljoin(response.url, '/') + + m3u8 = re.sub(r'^/', r'{}'.format(base_url), m3u8, flags=re.I|re.M) + m3u8 = re.sub(r'URI="/', r'URI="{}'.format(base_url), m3u8, flags=re.I|re.M) + + ## Convert to proxy paths + m3u8 = re.sub(r'(https?)://', r'{}\1://'.format(PROXY_PATH), m3u8, flags=re.I) + + m3u8 = m3u8.encode('utf8') + + if ADDON_DEV: + m3u8 = b"\n".join([ll.rstrip() for ll in m3u8.splitlines() if ll.strip()]) + log.debug('Time taken: {}'.format(time.time() - start)) + with open(xbmc.translatePath('special://temp/'+file_name+'-out.m3u8'), 'wb') as f: + f.write(m3u8) + + response.stream.content = m3u8 + + def _proxy_request(self, method, url): + self._session['redirecting'] = False + + if not url.startswith('http'): + response = Response() + response.headers = {} + response.status_code = 200 + response.stream = ResponseStream(response) + + with open(url, 'rb') as f: + response.stream.content = f.read() + + remove_file(url) + return response + + debug = self._session.get('debug_all') or self._session.get('debug_{}'.format(method.lower())) + if self._post_data and debug: + with open(xbmc.translatePath('special://temp/{}-request.txt').format(method.lower()), 'wb') as f: + f.write(self._post_data) + + if not self._session.get('session'): + self._session['session'] = RawSession() + else: + self._session['session'].headers.clear() + #self._session['session'].cookies.clear() #lets handle cookies in session + + ## Fix any double // in url + url = fix_url(url) + + retries = 3 + # some reason we get connection errors every so often when using a session. something to do with the socket + for i in range(retries): + try: + response = self._session['session'].request(method=method, url=url, headers=self._headers, data=self._post_data, allow_redirects=False, stream=True) + except ConnectionError as e: + if 'Connection aborted' not in str(e) or i == retries-1: + log.exception(e) + raise + except Exception as e: + log.exception(e) + raise + else: + break + + response.stream = ResponseStream(response) + + log.debug('{} OUT: {} ({})'.format(method.upper(), url, response.status_code)) + + headers = {} + for header in response.headers: + if header.lower() not in REMOVE_OUT_HEADERS: + headers[header.lower()] = response.headers[header] + + response.headers = headers + + if debug: + with open(xbmc.translatePath('special://temp/{}-response.txt').format(method.lower()), 'wb') as f: + f.write(response.stream.content) + + if 'location' in response.headers: + if '://' not in response.headers['location']: + response.headers['location'] = urljoin(url, response.headers['location']) + + self._session['redirecting'] = True + if url == self._session.get('manifest'): + self._session['manifest'] = response.headers['location'] + + response.headers['location'] = PROXY_PATH + response.headers['location'] + response.stream.content = b'' + + if 'set-cookie' in response.headers: + log.debug('set-cookie: {}'.format(response.headers['set-cookie'])) + response.headers.pop('set-cookie') #lets handle cookies in session + # base_url = urljoin(url, '/') + # response.headers['set-cookie'] = re.sub(r'domain=([^ ;]*)', r'domain={}'.format(PROXY_HOST), response.headers['set-cookie'], flags=re.I) + # response.headers['set-cookie'] = re.sub(r'path=(/[^ ;]*)', r'path=/{}\1'.format(base_url), response.headers['set-cookie'], flags=re.I) + + return response + + def _output_headers(self, response): + self.send_response(response.status_code) + + if 'content-length' in response.headers: + response.headers['keep-alive'] = 'timeout=10, max=1000' + else: + response.headers['connection'] = 'close' + + for d in list(response.headers.items()): + self.send_header(d[0], d[1]) + + self.end_headers() + + def _output_response(self, response): + self._output_headers(response) + + for chunk in response.stream.iter_content(): + try: + self.wfile.write(chunk) + except Exception as e: + break + + def do_HEAD(self): + url = self._get_url() + log.debug('HEAD IN: {}'.format(url)) + response = self._proxy_request('HEAD', url) + self._output_response(response) + + def do_POST(self): + url = self._get_url() + log.debug('POST IN: {}'.format(url)) + response = self._proxy_request('POST', url) + self._output_response(response) + +class Response(object): + pass + +class ResponseStream(object): + def __init__(self, response): + self._response = response + self._bytes = None + + @property + def content(self): + if not self._bytes: + self.content = self._response.content + + return self._bytes + + @content.setter + def content(self, _bytes): + if not type(_bytes) is bytes: + raise Exception('Only bytes allowed when setting content') + + self._bytes = _bytes + self._response.headers['content-length'] = str(len(_bytes)) + self._response.headers.pop('content-range', None) + self._response.headers.pop('content-encoding', None) + + def iter_content(self): + if self._bytes is not None: + yield self._bytes + else: + while True: + chunk = self._response.raw.read(CHUNK_SIZE) + if not chunk: + break + + yield chunk + +class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + +class Proxy(object): + def start(self): + log.debug("Starting Proxy {}:{}".format(PROXY_HOST, PROXY_PORT)) + self._server = ThreadedHTTPServer((PROXY_HOST, PROXY_PORT), RequestHandler) + self._server.allow_reuse_address = True + self._httpd_thread = threading.Thread(target=self._server.serve_forever) + self._httpd_thread.start() + log.info("Proxy bound to {}:{}".format(PROXY_HOST, PROXY_PORT)) + + def stop(self): + self._server.shutdown() + self._server.server_close() + self._server.socket.close() + self._httpd_thread.join() + log.debug("Proxy Server: Stopped") diff --git a/script.module.slyguy/resources/lib/service.py b/script.module.slyguy/resources/lib/service.py new file mode 100644 index 00000000..a5dc75dc --- /dev/null +++ b/script.module.slyguy/resources/lib/service.py @@ -0,0 +1,139 @@ +import os +import json +from time import time +from threading import Thread +from distutils.version import LooseVersion + +from kodi_six import xbmc + +from slyguy import userdata, gui, router, settings +from slyguy.session import Session +from slyguy.util import hash_6, kodi_rpc, get_addon +from slyguy.log import log +from slyguy.constants import ROUTE_SERVICE, ROUTE_SERVICE_INTERVAL, KODI_VERSION + +from .proxy import Proxy +from .monitor import monitor +from .player import Player +from .language import _ +from .constants import * + +session = Session(timeout=15) + +def _check_updates(): + #Leia and below. Matrix and above use X-Kodi-Recheck-After + if KODI_VERSION > 18: + return + + _time = int(time()) + if _time < userdata.get('last_updates_check', 0) + UPDATES_CHECK_TIME: + return + + userdata.set('last_updates_check', _time) + + new_md5 = session.get(ADDONS_MD5).text.split(' ')[0] + if new_md5 == userdata.get('addon_md5'): + return + + userdata.set('addon_md5', new_md5) + + updates = [] + slyguy_addons = session.gz_json(ADDONS_URL) + slyguy_installed = [x['addonid'] for x in kodi_rpc('Addons.GetAddons', {'installed': True, 'enabled': True})['addons'] if x['addonid'] in slyguy_addons] + + for addon_id in slyguy_installed: + addon = get_addon(addon_id, install=False) + if not addon: + continue + + cur_version = addon.getAddonInfo('version') + new_version = slyguy_addons[addon_id]['version'] + + if LooseVersion(cur_version) < LooseVersion(new_version): + updates.append([addon_id, cur_version, new_version]) + + if not updates: + return + + log.debug('Updating repos due to {} addon updates'.format(len(updates))) + xbmc.executebuiltin('UpdateAddonRepos') + +def _check_news(): + _time = int(time()) + if _time < userdata.get('last_news_check', 0) + NEWS_CHECK_TIME: + return + + userdata.set('last_news_check', _time) + + news = session.gz_json(NEWS_URL) + if not news: + return + + if 'id' not in news or news['id'] == userdata.get('last_news_id'): + return + + userdata.set('last_news_id', news['id']) + + if _time > news.get('timestamp', _time) + NEWS_MAX_TIME: + log.debug("news is too old to show") + return + + if news['type'] == 'next_plugin_msg': + userdata.set('_next_plugin_msg', news['message']) + + elif news['type'] == 'addon_release': + if news.get('requires') and not get_addon(news['requires'], install=False): + log.debug('addon_release {} requires addon {} which is not installed'.format(news['addon_id'], news['requires'])) + return + + if get_addon(news['addon_id'], install=False): + log.debug('addon_release {} already installed'.format(news['addon_id'])) + return + + def _interact_thread(): + if gui.yes_no(news['message'], news.get('heading', _.NEWS_HEADING)): + addon = get_addon(news['addon_id'], install=True) + if not addon: + return + + url = router.url_for('', _addon_id=news['addon_id']) + xbmc.executebuiltin('ActivateWindow(Videos,{})'.format(url)) + + thread = Thread(target=_interact_thread) + thread.daemon = True + thread.start() + +def start(): + log.debug('Shared Service: Started') + + player = Player() + proxy = Proxy() + + try: + proxy.start() + except Exception as e: + log.error('Failed to start proxy server') + log.exception(e) + + ## Inital wait on boot + monitor.waitForAbort(5) + + try: + while not monitor.abortRequested(): + try: _check_news() + except Exception as e: log.exception(e) + + try: _check_updates() + except Exception as e: log.exception(e) + + if monitor.waitForAbort(5): + break + except KeyboardInterrupt: + pass + except Exception as e: + log.exception(e) + + try: proxy.stop() + except: pass + + log.debug('Shared Service: Stopped') \ No newline at end of file diff --git a/script.module.slyguy/resources/modules/__init__.py b/script.module.slyguy/resources/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/script.module.slyguy/resources/modules/_backports/__init__.py b/script.module.slyguy/resources/modules/_backports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/script.module.slyguy/resources/modules/_backports/functools_lru_cache.py b/script.module.slyguy/resources/modules/_backports/functools_lru_cache.py new file mode 100644 index 00000000..707c6c76 --- /dev/null +++ b/script.module.slyguy/resources/modules/_backports/functools_lru_cache.py @@ -0,0 +1,184 @@ +from __future__ import absolute_import + +import functools +from collections import namedtuple +from threading import RLock + +_CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"]) + + +@functools.wraps(functools.update_wrapper) +def update_wrapper(wrapper, + wrapped, + assigned = functools.WRAPPER_ASSIGNMENTS, + updated = functools.WRAPPER_UPDATES): + """ + Patch two bugs in functools.update_wrapper. + """ + # workaround for http://bugs.python.org/issue3445 + assigned = tuple(attr for attr in assigned if hasattr(wrapped, attr)) + wrapper = functools.update_wrapper(wrapper, wrapped, assigned, updated) + # workaround for https://bugs.python.org/issue17482 + wrapper.__wrapped__ = wrapped + return wrapper + + +class _HashedSeq(list): + __slots__ = 'hashvalue' + + def __init__(self, tup, hash=hash): + self[:] = tup + self.hashvalue = hash(tup) + + def __hash__(self): + return self.hashvalue + + +def _make_key(args, kwds, typed, + kwd_mark=(object(),), + fasttypes=set([int, str, frozenset, type(None)]), + sorted=sorted, tuple=tuple, type=type, len=len): + 'Make a cache key from optionally typed positional and keyword arguments' + key = args + if kwds: + sorted_items = sorted(kwds.items()) + key += kwd_mark + for item in sorted_items: + key += item + if typed: + key += tuple(type(v) for v in args) + if kwds: + key += tuple(type(v) for k, v in sorted_items) + elif len(key) == 1 and type(key[0]) in fasttypes: + return key[0] + return _HashedSeq(key) + + +def lru_cache(maxsize=100, typed=False): + """Least-recently-used cache decorator. + + If *maxsize* is set to None, the LRU features are disabled and the cache + can grow without bound. + + If *typed* is True, arguments of different types will be cached separately. + For example, f(3.0) and f(3) will be treated as distinct calls with + distinct results. + + Arguments to the cached function must be hashable. + + View the cache statistics named tuple (hits, misses, maxsize, currsize) with + f.cache_info(). Clear the cache and statistics with f.cache_clear(). + Access the underlying function with f.__wrapped__. + + See: http://en.wikipedia.org/wiki/Cache_algorithms#Least_Recently_Used + + """ + + # Users should only access the lru_cache through its public API: + # cache_info, cache_clear, and f.__wrapped__ + # The internals of the lru_cache are encapsulated for thread safety and + # to allow the implementation to change (including a possible C version). + + def decorating_function(user_function): + + cache = dict() + stats = [0, 0] # make statistics updateable non-locally + HITS, MISSES = 0, 1 # names for the stats fields + make_key = _make_key + cache_get = cache.get # bound method to lookup key or return None + _len = len # localize the global len() function + lock = RLock() # because linkedlist updates aren't threadsafe + root = [] # root of the circular doubly linked list + root[:] = [root, root, None, None] # initialize by pointing to self + nonlocal_root = [root] # make updateable non-locally + PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields + + if maxsize == 0: + + def wrapper(*args, **kwds): + # no caching, just do a statistics update after a successful call + result = user_function(*args, **kwds) + stats[MISSES] += 1 + return result + + elif maxsize is None: + + def wrapper(*args, **kwds): + # simple caching without ordering or size limit + key = make_key(args, kwds, typed) + result = cache_get(key, root) # root used here as a unique not-found sentinel + if result is not root: + stats[HITS] += 1 + return result + result = user_function(*args, **kwds) + cache[key] = result + stats[MISSES] += 1 + return result + + else: + + def wrapper(*args, **kwds): + # size limited caching that tracks accesses by recency + key = make_key(args, kwds, typed) if kwds or typed else args + with lock: + link = cache_get(key) + if link is not None: + # record recent use of the key by moving it to the front of the list + root, = nonlocal_root + link_prev, link_next, key, result = link + link_prev[NEXT] = link_next + link_next[PREV] = link_prev + last = root[PREV] + last[NEXT] = root[PREV] = link + link[PREV] = last + link[NEXT] = root + stats[HITS] += 1 + return result + result = user_function(*args, **kwds) + with lock: + root, = nonlocal_root + if key in cache: + # getting here means that this same key was added to the + # cache while the lock was released. since the link + # update is already done, we need only return the + # computed result and update the count of misses. + pass + elif _len(cache) >= maxsize: + # use the old root to store the new key and result + oldroot = root + oldroot[KEY] = key + oldroot[RESULT] = result + # empty the oldest link and make it the new root + root = nonlocal_root[0] = oldroot[NEXT] + oldkey = root[KEY] + root[KEY] = root[RESULT] = None + # now update the cache dictionary for the new links + del cache[oldkey] + cache[key] = oldroot + else: + # put result in a new link at the front of the list + last = root[PREV] + link = [last, root, key, result] + last[NEXT] = root[PREV] = cache[key] = link + stats[MISSES] += 1 + return result + + def cache_info(): + """Report cache statistics""" + with lock: + return _CacheInfo(stats[HITS], stats[MISSES], maxsize, len(cache)) + + def cache_clear(): + """Clear the cache and cache statistics""" + with lock: + cache.clear() + root = nonlocal_root[0] + root[:] = [root, root, None, None] + stats[:] = [0, 0] + + wrapper.__wrapped__ = user_function + wrapper.cache_info = cache_info + wrapper.cache_clear = cache_clear + return update_wrapper(wrapper, user_function) + + return decorating_function diff --git a/script.module.slyguy/resources/modules/_backports/ssl_match_hostname/__init__.py b/script.module.slyguy/resources/modules/_backports/ssl_match_hostname/__init__.py new file mode 100644 index 00000000..cdfef013 --- /dev/null +++ b/script.module.slyguy/resources/modules/_backports/ssl_match_hostname/__init__.py @@ -0,0 +1,204 @@ +"""The match_hostname() function from Python 3.7.0, essential when using SSL.""" + +import sys +import socket as _socket + +try: + # Divergence: Python-3.7+'s _ssl has this exception type but older Pythons do not + from _ssl import SSLCertVerificationError + CertificateError = SSLCertVerificationError +except: + class CertificateError(ValueError): + pass + + +__version__ = '3.7.0.1' + + +# Divergence: Added to deal with ipaddess as bytes on python2 +def _to_text(obj): + if isinstance(obj, str) and sys.version_info < (3,): + obj = unicode(obj, encoding='ascii', errors='strict') + elif sys.version_info >= (3,) and isinstance(obj, bytes): + obj = str(obj, encoding='ascii', errors='strict') + return obj + + +def _to_bytes(obj): + if isinstance(obj, str) and sys.version_info >= (3,): + obj = bytes(obj, encoding='ascii', errors='strict') + elif sys.version_info < (3,) and isinstance(obj, unicode): + obj = obj.encode('ascii', 'strict') + return obj + + +def _dnsname_match(dn, hostname): + """Matching according to RFC 6125, section 6.4.3 + + - Hostnames are compared lower case. + - For IDNA, both dn and hostname must be encoded as IDN A-label (ACE). + - Partial wildcards like 'www*.example.org', multiple wildcards, sole + wildcard or wildcards in labels other then the left-most label are not + supported and a CertificateError is raised. + - A wildcard must match at least one character. + """ + if not dn: + return False + + wildcards = dn.count('*') + # speed up common case w/o wildcards + if not wildcards: + return dn.lower() == hostname.lower() + + if wildcards > 1: + # Divergence .format() to percent formatting for Python < 2.6 + raise CertificateError( + "too many wildcards in certificate DNS name: %s" % repr(dn)) + + dn_leftmost, sep, dn_remainder = dn.partition('.') + + if '*' in dn_remainder: + # Only match wildcard in leftmost segment. + # Divergence .format() to percent formatting for Python < 2.6 + raise CertificateError( + "wildcard can only be present in the leftmost label: " + "%s." % repr(dn)) + + if not sep: + # no right side + # Divergence .format() to percent formatting for Python < 2.6 + raise CertificateError( + "sole wildcard without additional labels are not support: " + "%s." % repr(dn)) + + if dn_leftmost != '*': + # no partial wildcard matching + # Divergence .format() to percent formatting for Python < 2.6 + raise CertificateError( + "partial wildcards in leftmost label are not supported: " + "%s." % repr(dn)) + + hostname_leftmost, sep, hostname_remainder = hostname.partition('.') + if not hostname_leftmost or not sep: + # wildcard must match at least one char + return False + return dn_remainder.lower() == hostname_remainder.lower() + + +def _inet_paton(ipname): + """Try to convert an IP address to packed binary form + + Supports IPv4 addresses on all platforms and IPv6 on platforms with IPv6 + support. + """ + # inet_aton() also accepts strings like '1' + # Divergence: We make sure we have native string type for all python versions + try: + b_ipname = _to_bytes(ipname) + except UnicodeError: + raise ValueError("%s must be an all-ascii string." % repr(ipname)) + + # Set ipname in native string format + if sys.version_info < (3,): + n_ipname = b_ipname + else: + n_ipname = ipname + + if n_ipname.count('.') == 3: + try: + return _socket.inet_aton(n_ipname) + # Divergence: OSError on late python3. socket.error earlier. + # Null bytes generate ValueError on python3(we want to raise + # ValueError anyway), TypeError # earlier + except (OSError, _socket.error, TypeError): + pass + + try: + return _socket.inet_pton(_socket.AF_INET6, n_ipname) + # Divergence: OSError on late python3. socket.error earlier. + # Null bytes generate ValueError on python3(we want to raise + # ValueError anyway), TypeError # earlier + except (OSError, _socket.error, TypeError): + # Divergence .format() to percent formatting for Python < 2.6 + raise ValueError("%s is neither an IPv4 nor an IP6 " + "address." % repr(ipname)) + except AttributeError: + # AF_INET6 not available + pass + + # Divergence .format() to percent formatting for Python < 2.6 + raise ValueError("%s is not an IPv4 address." % repr(ipname)) + + +def _ipaddress_match(ipname, host_ip): + """Exact matching of IP addresses. + + RFC 6125 explicitly doesn't define an algorithm for this + (section 1.7.2 - "Out of Scope"). + """ + # OpenSSL may add a trailing newline to a subjectAltName's IP address + ip = _inet_paton(ipname.rstrip()) + return ip == host_ip + + +def match_hostname(cert, hostname): + """Verify that *cert* (in decoded format as returned by + SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 and RFC 6125 + rules are followed. + + The function matches IP addresses rather than dNSNames if hostname is a + valid ipaddress string. IPv4 addresses are supported on all platforms. + IPv6 addresses are supported on platforms with IPv6 support (AF_INET6 + and inet_pton). + + CertificateError is raised on failure. On success, the function + returns nothing. + """ + if not cert: + raise ValueError("empty or no certificate, match_hostname needs a " + "SSL socket or SSL context with either " + "CERT_OPTIONAL or CERT_REQUIRED") + try: + # Divergence: Deal with hostname as bytes + host_ip = _inet_paton(_to_text(hostname)) + except ValueError: + # Not an IP address (common case) + host_ip = None + except UnicodeError: + # Divergence: Deal with hostname as byte strings. + # IP addresses should be all ascii, so we consider it not + # an IP address if this fails + host_ip = None + dnsnames = [] + san = cert.get('subjectAltName', ()) + for key, value in san: + if key == 'DNS': + if host_ip is None and _dnsname_match(value, hostname): + return + dnsnames.append(value) + elif key == 'IP Address': + if host_ip is not None and _ipaddress_match(value, host_ip): + return + dnsnames.append(value) + if not dnsnames: + # The subject is only checked when there is no dNSName entry + # in subjectAltName + for sub in cert.get('subject', ()): + for key, value in sub: + # XXX according to RFC 2818, the most specific Common Name + # must be used. + if key == 'commonName': + if _dnsname_match(value, hostname): + return + dnsnames.append(value) + if len(dnsnames) > 1: + raise CertificateError("hostname %r " + "doesn't match either of %s" + % (hostname, ', '.join(map(repr, dnsnames)))) + elif len(dnsnames) == 1: + raise CertificateError("hostname %r " + "doesn't match %r" + % (hostname, dnsnames[0])) + else: + raise CertificateError("no appropriate commonName or " + "subjectAltName fields were found") diff --git a/script.module.slyguy/resources/modules/arrow/__init__.py b/script.module.slyguy/resources/modules/arrow/__init__.py new file mode 100644 index 00000000..2883527b --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from ._version import __version__ +from .api import get, now, utcnow +from .arrow import Arrow +from .factory import ArrowFactory +from .formatter import ( + FORMAT_ATOM, + FORMAT_COOKIE, + FORMAT_RFC822, + FORMAT_RFC850, + FORMAT_RFC1036, + FORMAT_RFC1123, + FORMAT_RFC2822, + FORMAT_RFC3339, + FORMAT_RSS, + FORMAT_W3C, +) +from .parser import ParserError diff --git a/script.module.slyguy/resources/modules/arrow/_version.py b/script.module.slyguy/resources/modules/arrow/_version.py new file mode 100644 index 00000000..66a62a27 --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/_version.py @@ -0,0 +1 @@ +__version__ = "0.15.7" diff --git a/script.module.slyguy/resources/modules/arrow/api.py b/script.module.slyguy/resources/modules/arrow/api.py new file mode 100644 index 00000000..c13fd714 --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/api.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +""" +Provides the default implementation of :class:`ArrowFactory ` +methods for use as a module API. + +""" + +from __future__ import absolute_import + +from arrow.factory import ArrowFactory + +# internal default factory. +_factory = ArrowFactory() + + +def get(*args, **kwargs): + """ Calls the default :class:`ArrowFactory ` ``get`` method. + + """ + + return _factory.get(*args, **kwargs) + + +get.__doc__ = _factory.get.__doc__ + + +def utcnow(): + """ Calls the default :class:`ArrowFactory ` ``utcnow`` method. + + """ + + return _factory.utcnow() + + +utcnow.__doc__ = _factory.utcnow.__doc__ + + +def now(tz=None): + """ Calls the default :class:`ArrowFactory ` ``now`` method. + + """ + + return _factory.now(tz) + + +now.__doc__ = _factory.now.__doc__ + + +def factory(type): + """ Returns an :class:`.ArrowFactory` for the specified :class:`Arrow ` + or derived type. + + :param type: the type, :class:`Arrow ` or derived. + + """ + + return ArrowFactory(type) + + +__all__ = ["get", "utcnow", "now", "factory"] diff --git a/script.module.slyguy/resources/modules/arrow/arrow.py b/script.module.slyguy/resources/modules/arrow/arrow.py new file mode 100644 index 00000000..3837ae36 --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/arrow.py @@ -0,0 +1,1494 @@ +# -*- coding: utf-8 -*- +""" +Provides the :class:`Arrow ` class, an enhanced ``datetime`` +replacement. + +""" + +from __future__ import absolute_import + +import calendar +import sys +from datetime import datetime, timedelta +from datetime import tzinfo as dt_tzinfo +from math import trunc + +from dateutil import tz as dateutil_tz +from dateutil.relativedelta import relativedelta + +from arrow import formatter, locales, parser, util + + +class Arrow(object): + """An :class:`Arrow ` object. + + Implements the ``datetime`` interface, behaving as an aware ``datetime`` while implementing + additional functionality. + + :param year: the calendar year. + :param month: the calendar month. + :param day: the calendar day. + :param hour: (optional) the hour. Defaults to 0. + :param minute: (optional) the minute, Defaults to 0. + :param second: (optional) the second, Defaults to 0. + :param microsecond: (optional) the microsecond. Defaults 0. + :param tzinfo: (optional) A timezone expression. Defaults to UTC. + + .. _tz-expr: + + Recognized timezone expressions: + + - A ``tzinfo`` object. + - A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'. + - A ``str`` in ISO 8601 style, as in '+07:00'. + - A ``str``, one of the following: 'local', 'utc', 'UTC'. + + Usage:: + + >>> import arrow + >>> arrow.Arrow(2013, 5, 5, 12, 30, 45) + + + """ + + resolution = datetime.resolution + + _ATTRS = ["year", "month", "day", "hour", "minute", "second", "microsecond"] + _ATTRS_PLURAL = ["{}s".format(a) for a in _ATTRS] + _MONTHS_PER_QUARTER = 3 + _SECS_PER_MINUTE = float(60) + _SECS_PER_HOUR = float(60 * 60) + _SECS_PER_DAY = float(60 * 60 * 24) + _SECS_PER_WEEK = float(60 * 60 * 24 * 7) + _SECS_PER_MONTH = float(60 * 60 * 24 * 30.5) + _SECS_PER_YEAR = float(60 * 60 * 24 * 365.25) + + def __init__( + self, year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None + ): + if tzinfo is None: + tzinfo = dateutil_tz.tzutc() + # detect that tzinfo is a pytz object (issue #626) + elif ( + isinstance(tzinfo, dt_tzinfo) + and hasattr(tzinfo, "localize") + and hasattr(tzinfo, "zone") + and tzinfo.zone + ): + tzinfo = parser.TzinfoParser.parse(tzinfo.zone) + elif util.isstr(tzinfo): + tzinfo = parser.TzinfoParser.parse(tzinfo) + + self._datetime = datetime( + year, month, day, hour, minute, second, microsecond, tzinfo + ) + + # factories: single object, both original and from datetime. + + @classmethod + def now(cls, tzinfo=None): + """Constructs an :class:`Arrow ` object, representing "now" in the given + timezone. + + :param tzinfo: (optional) a ``tzinfo`` object. Defaults to local time. + + Usage:: + + >>> arrow.now('Asia/Baku') + + + """ + + if tzinfo is None: + tzinfo = dateutil_tz.tzlocal() + dt = datetime.now(tzinfo) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + ) + + @classmethod + def utcnow(cls): + """ Constructs an :class:`Arrow ` object, representing "now" in UTC + time. + + Usage:: + + >>> arrow.utcnow() + + + """ + + dt = datetime.now(dateutil_tz.tzutc()) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + ) + + @classmethod + def fromtimestamp(cls, timestamp, tzinfo=None): + """ Constructs an :class:`Arrow ` object from a timestamp, converted to + the given timezone. + + :param timestamp: an ``int`` or ``float`` timestamp, or a ``str`` that converts to either. + :param tzinfo: (optional) a ``tzinfo`` object. Defaults to local time. + """ + + if tzinfo is None: + tzinfo = dateutil_tz.tzlocal() + elif util.isstr(tzinfo): + tzinfo = parser.TzinfoParser.parse(tzinfo) + + if not util.is_timestamp(timestamp): + raise ValueError( + "The provided timestamp '{}' is invalid.".format(timestamp) + ) + + timestamp = util.normalize_timestamp(float(timestamp)) + dt = datetime.fromtimestamp(timestamp, tzinfo) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + ) + + @classmethod + def utcfromtimestamp(cls, timestamp): + """Constructs an :class:`Arrow ` object from a timestamp, in UTC time. + + :param timestamp: an ``int`` or ``float`` timestamp, or a ``str`` that converts to either. + + """ + + if not util.is_timestamp(timestamp): + raise ValueError( + "The provided timestamp '{}' is invalid.".format(timestamp) + ) + + timestamp = util.normalize_timestamp(float(timestamp)) + dt = datetime.utcfromtimestamp(timestamp) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dateutil_tz.tzutc(), + ) + + @classmethod + def fromdatetime(cls, dt, tzinfo=None): + """ Constructs an :class:`Arrow ` object from a ``datetime`` and + optional replacement timezone. + + :param dt: the ``datetime`` + :param tzinfo: (optional) A :ref:`timezone expression `. Defaults to ``dt``'s + timezone, or UTC if naive. + + If you only want to replace the timezone of naive datetimes:: + + >>> dt + datetime.datetime(2013, 5, 5, 0, 0, tzinfo=tzutc()) + >>> arrow.Arrow.fromdatetime(dt, dt.tzinfo or 'US/Pacific') + + + """ + + if tzinfo is None: + if dt.tzinfo is None: + tzinfo = dateutil_tz.tzutc() + else: + tzinfo = dt.tzinfo + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + tzinfo, + ) + + @classmethod + def fromdate(cls, date, tzinfo=None): + """ Constructs an :class:`Arrow ` object from a ``date`` and optional + replacement timezone. Time values are set to 0. + + :param date: the ``date`` + :param tzinfo: (optional) A :ref:`timezone expression `. Defaults to UTC. + """ + + if tzinfo is None: + tzinfo = dateutil_tz.tzutc() + + return cls(date.year, date.month, date.day, tzinfo=tzinfo) + + @classmethod + def strptime(cls, date_str, fmt, tzinfo=None): + """ Constructs an :class:`Arrow ` object from a date string and format, + in the style of ``datetime.strptime``. Optionally replaces the parsed timezone. + + :param date_str: the date string. + :param fmt: the format string. + :param tzinfo: (optional) A :ref:`timezone expression `. Defaults to the parsed + timezone if ``fmt`` contains a timezone directive, otherwise UTC. + + Usage:: + + >>> arrow.Arrow.strptime('20-01-2019 15:49:10', '%d-%m-%Y %H:%M:%S') + + + """ + + dt = datetime.strptime(date_str, fmt) + if tzinfo is None: + tzinfo = dt.tzinfo + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + tzinfo, + ) + + # factories: ranges and spans + + @classmethod + def range(cls, frame, start, end=None, tz=None, limit=None): + """ Returns an iterator of :class:`Arrow ` objects, representing + points in time between two inputs. + + :param frame: The timeframe. Can be any ``datetime`` property (day, hour, minute...). + :param start: A datetime expression, the start of the range. + :param end: (optional) A datetime expression, the end of the range. + :param tz: (optional) A :ref:`timezone expression `. Defaults to + ``start``'s timezone, or UTC if ``start`` is naive. + :param limit: (optional) A maximum number of tuples to return. + + **NOTE**: The ``end`` or ``limit`` must be provided. Call with ``end`` alone to + return the entire range. Call with ``limit`` alone to return a maximum # of results from + the start. Call with both to cap a range at a maximum # of results. + + **NOTE**: ``tz`` internally **replaces** the timezones of both ``start`` and ``end`` before + iterating. As such, either call with naive objects and ``tz``, or aware objects from the + same timezone and no ``tz``. + + Supported frame values: year, quarter, month, week, day, hour, minute, second. + + Recognized datetime expressions: + + - An :class:`Arrow ` object. + - A ``datetime`` object. + + Usage:: + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 17, 15) + >>> for r in arrow.Arrow.range('hour', start, end): + ... print(repr(r)) + ... + + + + + + + **NOTE**: Unlike Python's ``range``, ``end`` *may* be included in the returned iterator:: + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 13, 30) + >>> for r in arrow.Arrow.range('hour', start, end): + ... print(repr(r)) + ... + + + + """ + + _, frame_relative, relative_steps = cls._get_frames(frame) + + tzinfo = cls._get_tzinfo(start.tzinfo if tz is None else tz) + + start = cls._get_datetime(start).replace(tzinfo=tzinfo) + end, limit = cls._get_iteration_params(end, limit) + end = cls._get_datetime(end).replace(tzinfo=tzinfo) + + current = cls.fromdatetime(start) + i = 0 + + while current <= end and i < limit: + i += 1 + yield current + + values = [getattr(current, f) for f in cls._ATTRS] + current = cls(*values, tzinfo=tzinfo) + relativedelta( + **{frame_relative: relative_steps} + ) + + @classmethod + def span_range(cls, frame, start, end, tz=None, limit=None, bounds="[)"): + """ Returns an iterator of tuples, each :class:`Arrow ` objects, + representing a series of timespans between two inputs. + + :param frame: The timeframe. Can be any ``datetime`` property (day, hour, minute...). + :param start: A datetime expression, the start of the range. + :param end: (optional) A datetime expression, the end of the range. + :param tz: (optional) A :ref:`timezone expression `. Defaults to + ``start``'s timezone, or UTC if ``start`` is naive. + :param limit: (optional) A maximum number of tuples to return. + :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies + whether to include or exclude the start and end values in each span in the range. '(' excludes + the start, '[' includes the start, ')' excludes the end, and ']' includes the end. + If the bounds are not specified, the default bound '[)' is used. + + **NOTE**: The ``end`` or ``limit`` must be provided. Call with ``end`` alone to + return the entire range. Call with ``limit`` alone to return a maximum # of results from + the start. Call with both to cap a range at a maximum # of results. + + **NOTE**: ``tz`` internally **replaces** the timezones of both ``start`` and ``end`` before + iterating. As such, either call with naive objects and ``tz``, or aware objects from the + same timezone and no ``tz``. + + Supported frame values: year, quarter, month, week, day, hour, minute, second. + + Recognized datetime expressions: + + - An :class:`Arrow ` object. + - A ``datetime`` object. + + **NOTE**: Unlike Python's ``range``, ``end`` will *always* be included in the returned + iterator of timespans. + + Usage: + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 17, 15) + >>> for r in arrow.Arrow.span_range('hour', start, end): + ... print(r) + ... + (, ) + (, ) + (, ) + (, ) + (, ) + (, ) + + """ + + tzinfo = cls._get_tzinfo(start.tzinfo if tz is None else tz) + start = cls.fromdatetime(start, tzinfo).span(frame)[0] + _range = cls.range(frame, start, end, tz, limit) + return (r.span(frame, bounds=bounds) for r in _range) + + @classmethod + def interval(cls, frame, start, end, interval=1, tz=None, bounds="[)"): + """ Returns an iterator of tuples, each :class:`Arrow ` objects, + representing a series of intervals between two inputs. + + :param frame: The timeframe. Can be any ``datetime`` property (day, hour, minute...). + :param start: A datetime expression, the start of the range. + :param end: (optional) A datetime expression, the end of the range. + :param interval: (optional) Time interval for the given time frame. + :param tz: (optional) A timezone expression. Defaults to UTC. + :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies + whether to include or exclude the start and end values in the intervals. '(' excludes + the start, '[' includes the start, ')' excludes the end, and ']' includes the end. + If the bounds are not specified, the default bound '[)' is used. + + Supported frame values: year, quarter, month, week, day, hour, minute, second + + Recognized datetime expressions: + + - An :class:`Arrow ` object. + - A ``datetime`` object. + + Recognized timezone expressions: + + - A ``tzinfo`` object. + - A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'. + - A ``str`` in ISO 8601 style, as in '+07:00'. + - A ``str``, one of the following: 'local', 'utc', 'UTC'. + + Usage: + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 17, 15) + >>> for r in arrow.Arrow.interval('hour', start, end, 2): + ... print r + ... + (, ) + (, ) + (, ) + """ + if interval < 1: + raise ValueError("interval has to be a positive integer") + + spanRange = iter(cls.span_range(frame, start, end, tz, bounds=bounds)) + while True: + try: + intvlStart, intvlEnd = next(spanRange) + for _ in range(interval - 1): + _, intvlEnd = next(spanRange) + yield intvlStart, intvlEnd + except StopIteration: + return + + # representations + + def __repr__(self): + return "<{} [{}]>".format(self.__class__.__name__, self.__str__()) + + def __str__(self): + return self._datetime.isoformat() + + def __format__(self, formatstr): + + if len(formatstr) > 0: + return self.format(formatstr) + + return str(self) + + def __hash__(self): + return self._datetime.__hash__() + + # attributes & properties + + def __getattr__(self, name): + + if name == "week": + return self.isocalendar()[1] + + if name == "quarter": + return int((self.month - 1) / self._MONTHS_PER_QUARTER) + 1 + + if not name.startswith("_"): + value = getattr(self._datetime, name, None) + + if value is not None: + return value + + return object.__getattribute__(self, name) + + @property + def tzinfo(self): + """ Gets the ``tzinfo`` of the :class:`Arrow ` object. + + Usage:: + + >>> arw=arrow.utcnow() + >>> arw.tzinfo + tzutc() + + """ + + return self._datetime.tzinfo + + @tzinfo.setter + def tzinfo(self, tzinfo): + """ Sets the ``tzinfo`` of the :class:`Arrow ` object. """ + + self._datetime = self._datetime.replace(tzinfo=tzinfo) + + @property + def datetime(self): + """ Returns a datetime representation of the :class:`Arrow ` object. + + Usage:: + + >>> arw=arrow.utcnow() + >>> arw.datetime + datetime.datetime(2019, 1, 24, 16, 35, 27, 276649, tzinfo=tzutc()) + + """ + + return self._datetime + + @property + def naive(self): + """ Returns a naive datetime representation of the :class:`Arrow ` + object. + + Usage:: + + >>> nairobi = arrow.now('Africa/Nairobi') + >>> nairobi + + >>> nairobi.naive + datetime.datetime(2019, 1, 23, 19, 27, 12, 297999) + + """ + + return self._datetime.replace(tzinfo=None) + + @property + def timestamp(self): + """ Returns a timestamp representation of the :class:`Arrow ` object, in + UTC time. + + Usage:: + + >>> arrow.utcnow().timestamp + 1548260567 + + """ + + return calendar.timegm(self._datetime.utctimetuple()) + + @property + def float_timestamp(self): + """ Returns a floating-point representation of the :class:`Arrow ` + object, in UTC time. + + Usage:: + + >>> arrow.utcnow().float_timestamp + 1548260516.830896 + + """ + + return self.timestamp + float(self.microsecond) / 1000000 + + # mutation and duplication. + + def clone(self): + """ Returns a new :class:`Arrow ` object, cloned from the current one. + + Usage: + + >>> arw = arrow.utcnow() + >>> cloned = arw.clone() + + """ + + return self.fromdatetime(self._datetime) + + def replace(self, **kwargs): + """ Returns a new :class:`Arrow ` object with attributes updated + according to inputs. + + Use property names to set their value absolutely:: + + >>> import arrow + >>> arw = arrow.utcnow() + >>> arw + + >>> arw.replace(year=2014, month=6) + + + You can also replace the timezone without conversion, using a + :ref:`timezone expression `:: + + >>> arw.replace(tzinfo=tz.tzlocal()) + + + """ + + absolute_kwargs = {} + + for key, value in kwargs.items(): + + if key in self._ATTRS: + absolute_kwargs[key] = value + elif key in ["week", "quarter"]: + raise AttributeError("setting absolute {} is not supported".format(key)) + elif key != "tzinfo": + raise AttributeError('unknown attribute: "{}"'.format(key)) + + current = self._datetime.replace(**absolute_kwargs) + + tzinfo = kwargs.get("tzinfo") + + if tzinfo is not None: + tzinfo = self._get_tzinfo(tzinfo) + current = current.replace(tzinfo=tzinfo) + + return self.fromdatetime(current) + + def shift(self, **kwargs): + """ Returns a new :class:`Arrow ` object with attributes updated + according to inputs. + + Use pluralized property names to relatively shift their current value: + + >>> import arrow + >>> arw = arrow.utcnow() + >>> arw + + >>> arw.shift(years=1, months=-1) + + + Day-of-the-week relative shifting can use either Python's weekday numbers + (Monday = 0, Tuesday = 1 .. Sunday = 6) or using dateutil.relativedelta's + day instances (MO, TU .. SU). When using weekday numbers, the returned + date will always be greater than or equal to the starting date. + + Using the above code (which is a Saturday) and asking it to shift to Saturday: + + >>> arw.shift(weekday=5) + + + While asking for a Monday: + + >>> arw.shift(weekday=0) + + + """ + + relative_kwargs = {} + additional_attrs = ["weeks", "quarters", "weekday"] + + for key, value in kwargs.items(): + + if key in self._ATTRS_PLURAL or key in additional_attrs: + relative_kwargs[key] = value + else: + raise AttributeError( + "Invalid shift time frame. Please select one of the following: {}.".format( + ", ".join(self._ATTRS_PLURAL + additional_attrs) + ) + ) + + # core datetime does not support quarters, translate to months. + relative_kwargs.setdefault("months", 0) + relative_kwargs["months"] += ( + relative_kwargs.pop("quarters", 0) * self._MONTHS_PER_QUARTER + ) + + current = self._datetime + relativedelta(**relative_kwargs) + + return self.fromdatetime(current) + + def to(self, tz): + """ Returns a new :class:`Arrow ` object, converted + to the target timezone. + + :param tz: A :ref:`timezone expression `. + + Usage:: + + >>> utc = arrow.utcnow() + >>> utc + + + >>> utc.to('US/Pacific') + + + >>> utc.to(tz.tzlocal()) + + + >>> utc.to('-07:00') + + + >>> utc.to('local') + + + >>> utc.to('local').to('utc') + + + """ + + if not isinstance(tz, dt_tzinfo): + tz = parser.TzinfoParser.parse(tz) + + dt = self._datetime.astimezone(tz) + + return self.__class__( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + ) + + @classmethod + def _validate_bounds(cls, bounds): + if bounds != "()" and bounds != "(]" and bounds != "[)" and bounds != "[]": + raise AttributeError( + 'Invalid bounds. Please select between "()", "(]", "[)", or "[]".' + ) + + def span(self, frame, count=1, bounds="[)"): + """ Returns two new :class:`Arrow ` objects, representing the timespan + of the :class:`Arrow ` object in a given timeframe. + + :param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...). + :param count: (optional) the number of frames to span. + :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies + whether to include or exclude the start and end values in the span. '(' excludes + the start, '[' includes the start, ')' excludes the end, and ']' includes the end. + If the bounds are not specified, the default bound '[)' is used. + + Supported frame values: year, quarter, month, week, day, hour, minute, second. + + Usage:: + + >>> arrow.utcnow() + + + >>> arrow.utcnow().span('hour') + (, ) + + >>> arrow.utcnow().span('day') + (, ) + + >>> arrow.utcnow().span('day', count=2) + (, ) + + >>> arrow.utcnow().span('day', bounds='[]') + (, ) + + """ + + self._validate_bounds(bounds) + + frame_absolute, frame_relative, relative_steps = self._get_frames(frame) + + if frame_absolute == "week": + attr = "day" + elif frame_absolute == "quarter": + attr = "month" + else: + attr = frame_absolute + + index = self._ATTRS.index(attr) + frames = self._ATTRS[: index + 1] + + values = [getattr(self, f) for f in frames] + + for _ in range(3 - len(values)): + values.append(1) + + floor = self.__class__(*values, tzinfo=self.tzinfo) + + if frame_absolute == "week": + floor = floor + relativedelta(days=-(self.isoweekday() - 1)) + elif frame_absolute == "quarter": + floor = floor + relativedelta(months=-((self.month - 1) % 3)) + + ceil = floor + relativedelta(**{frame_relative: count * relative_steps}) + + if bounds[0] == "(": + floor += relativedelta(microseconds=1) + + if bounds[1] == ")": + ceil += relativedelta(microseconds=-1) + + return floor, ceil + + def floor(self, frame): + """ Returns a new :class:`Arrow ` object, representing the "floor" + of the timespan of the :class:`Arrow ` object in a given timeframe. + Equivalent to the first element in the 2-tuple returned by + :func:`span `. + + :param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...). + + Usage:: + + >>> arrow.utcnow().floor('hour') + + """ + + return self.span(frame)[0] + + def ceil(self, frame): + """ Returns a new :class:`Arrow ` object, representing the "ceiling" + of the timespan of the :class:`Arrow ` object in a given timeframe. + Equivalent to the second element in the 2-tuple returned by + :func:`span `. + + :param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...). + + Usage:: + + >>> arrow.utcnow().ceil('hour') + + """ + + return self.span(frame)[1] + + # string output and formatting. + + def format(self, fmt="YYYY-MM-DD HH:mm:ssZZ", locale="en_us"): + """ Returns a string representation of the :class:`Arrow ` object, + formatted according to a format string. + + :param fmt: the format string. + + Usage:: + + >>> arrow.utcnow().format('YYYY-MM-DD HH:mm:ss ZZ') + '2013-05-09 03:56:47 -00:00' + + >>> arrow.utcnow().format('X') + '1368071882' + + >>> arrow.utcnow().format('MMMM DD, YYYY') + 'May 09, 2013' + + >>> arrow.utcnow().format() + '2013-05-09 03:56:47 -00:00' + + """ + + return formatter.DateTimeFormatter(locale).format(self._datetime, fmt) + + def humanize( + self, other=None, locale="en_us", only_distance=False, granularity="auto" + ): + """ Returns a localized, humanized representation of a relative difference in time. + + :param other: (optional) an :class:`Arrow ` or ``datetime`` object. + Defaults to now in the current :class:`Arrow ` object's timezone. + :param locale: (optional) a ``str`` specifying a locale. Defaults to 'en_us'. + :param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part. + :param granularity: (optional) defines the precision of the output. Set it to strings 'second', 'minute', + 'hour', 'day', 'week', 'month' or 'year' or a list of any combination of these strings + + Usage:: + + >>> earlier = arrow.utcnow().shift(hours=-2) + >>> earlier.humanize() + '2 hours ago' + + >>> later = earlier.shift(hours=4) + >>> later.humanize(earlier) + 'in 4 hours' + + """ + + locale_name = locale + locale = locales.get_locale(locale) + + if other is None: + utc = datetime.utcnow().replace(tzinfo=dateutil_tz.tzutc()) + dt = utc.astimezone(self._datetime.tzinfo) + + elif isinstance(other, Arrow): + dt = other._datetime + + elif isinstance(other, datetime): + if other.tzinfo is None: + dt = other.replace(tzinfo=self._datetime.tzinfo) + else: + dt = other.astimezone(self._datetime.tzinfo) + + else: + raise TypeError( + "Invalid 'other' argument of type '{}'. " + "Argument must be of type None, Arrow, or datetime.".format( + type(other).__name__ + ) + ) + + if isinstance(granularity, list) and len(granularity) == 1: + granularity = granularity[0] + + delta = int(round(util.total_seconds(self._datetime - dt))) + sign = -1 if delta < 0 else 1 + diff = abs(delta) + delta = diff + + try: + if granularity == "auto": + if diff < 10: + return locale.describe("now", only_distance=only_distance) + + if diff < 45: + seconds = sign * delta + return locale.describe( + "seconds", seconds, only_distance=only_distance + ) + + elif diff < 90: + return locale.describe("minute", sign, only_distance=only_distance) + elif diff < 2700: + minutes = sign * int(max(delta / 60, 2)) + return locale.describe( + "minutes", minutes, only_distance=only_distance + ) + + elif diff < 5400: + return locale.describe("hour", sign, only_distance=only_distance) + elif diff < 79200: + hours = sign * int(max(delta / 3600, 2)) + return locale.describe("hours", hours, only_distance=only_distance) + + # anything less than 48 hours should be 1 day + elif diff < 172800: + return locale.describe("day", sign, only_distance=only_distance) + elif diff < 554400: + days = sign * int(max(delta / 86400, 2)) + return locale.describe("days", days, only_distance=only_distance) + + elif diff < 907200: + return locale.describe("week", sign, only_distance=only_distance) + elif diff < 2419200: + weeks = sign * int(max(delta / 604800, 2)) + return locale.describe("weeks", weeks, only_distance=only_distance) + + elif diff < 3888000: + return locale.describe("month", sign, only_distance=only_distance) + elif diff < 29808000: + self_months = self._datetime.year * 12 + self._datetime.month + other_months = dt.year * 12 + dt.month + + months = sign * int(max(abs(other_months - self_months), 2)) + + return locale.describe( + "months", months, only_distance=only_distance + ) + + elif diff < 47260800: + return locale.describe("year", sign, only_distance=only_distance) + else: + years = sign * int(max(delta / 31536000, 2)) + return locale.describe("years", years, only_distance=only_distance) + + elif util.isstr(granularity): + if granularity == "second": + delta = sign * delta + if abs(delta) < 2: + return locale.describe("now", only_distance=only_distance) + elif granularity == "minute": + delta = sign * delta / self._SECS_PER_MINUTE + elif granularity == "hour": + delta = sign * delta / self._SECS_PER_HOUR + elif granularity == "day": + delta = sign * delta / self._SECS_PER_DAY + elif granularity == "week": + delta = sign * delta / self._SECS_PER_WEEK + elif granularity == "month": + delta = sign * delta / self._SECS_PER_MONTH + elif granularity == "year": + delta = sign * delta / self._SECS_PER_YEAR + else: + raise AttributeError( + "Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'" + ) + + if trunc(abs(delta)) != 1: + granularity += "s" + return locale.describe(granularity, delta, only_distance=only_distance) + + else: + timeframes = [] + if "year" in granularity: + years = sign * delta / self._SECS_PER_YEAR + delta %= self._SECS_PER_YEAR + timeframes.append(["year", years]) + + if "month" in granularity: + months = sign * delta / self._SECS_PER_MONTH + delta %= self._SECS_PER_MONTH + timeframes.append(["month", months]) + + if "week" in granularity: + weeks = sign * delta / self._SECS_PER_WEEK + delta %= self._SECS_PER_WEEK + timeframes.append(["week", weeks]) + + if "day" in granularity: + days = sign * delta / self._SECS_PER_DAY + delta %= self._SECS_PER_DAY + timeframes.append(["day", days]) + + if "hour" in granularity: + hours = sign * delta / self._SECS_PER_HOUR + delta %= self._SECS_PER_HOUR + timeframes.append(["hour", hours]) + + if "minute" in granularity: + minutes = sign * delta / self._SECS_PER_MINUTE + delta %= self._SECS_PER_MINUTE + timeframes.append(["minute", minutes]) + + if "second" in granularity: + seconds = sign * delta + timeframes.append(["second", seconds]) + + if len(timeframes) < len(granularity): + raise AttributeError( + "Invalid level of granularity. " + "Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'." + ) + + for tf in timeframes: + # Make granularity plural if the delta is not equal to 1 + if trunc(abs(tf[1])) != 1: + tf[0] += "s" + return locale.describe_multi(timeframes, only_distance=only_distance) + + except KeyError as e: + raise ValueError( + "Humanization of the {} granularity is not currently translated in the '{}' locale. " + "Please consider making a contribution to this locale.".format( + e, locale_name + ) + ) + + # query functions + + def is_between(self, start, end, bounds="()"): + """ Returns a boolean denoting whether the specified date and time is between + the start and end dates and times. + + :param start: an :class:`Arrow ` object. + :param end: an :class:`Arrow ` object. + :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies + whether to include or exclude the start and end values in the range. '(' excludes + the start, '[' includes the start, ')' excludes the end, and ']' includes the end. + If the bounds are not specified, the default bound '()' is used. + + Usage:: + + >>> start = arrow.get(datetime(2013, 5, 5, 12, 30, 10)) + >>> end = arrow.get(datetime(2013, 5, 5, 12, 30, 36)) + >>> arrow.get(datetime(2013, 5, 5, 12, 30, 27)).is_between(start, end) + True + + >>> start = arrow.get(datetime(2013, 5, 5)) + >>> end = arrow.get(datetime(2013, 5, 8)) + >>> arrow.get(datetime(2013, 5, 8)).is_between(start, end, '[]') + True + + >>> start = arrow.get(datetime(2013, 5, 5)) + >>> end = arrow.get(datetime(2013, 5, 8)) + >>> arrow.get(datetime(2013, 5, 8)).is_between(start, end, '[)') + False + + """ + + self._validate_bounds(bounds) + + if not isinstance(start, Arrow): + raise TypeError( + "Can't parse start date argument type of '{}'".format(type(start)) + ) + + if not isinstance(end, Arrow): + raise TypeError( + "Can't parse end date argument type of '{}'".format(type(end)) + ) + + include_start = bounds[0] == "[" + include_end = bounds[1] == "]" + + target_timestamp = self.float_timestamp + start_timestamp = start.float_timestamp + end_timestamp = end.float_timestamp + + if include_start and include_end: + return ( + target_timestamp >= start_timestamp + and target_timestamp <= end_timestamp + ) + elif include_start and not include_end: + return ( + target_timestamp >= start_timestamp and target_timestamp < end_timestamp + ) + elif not include_start and include_end: + return ( + target_timestamp > start_timestamp and target_timestamp <= end_timestamp + ) + else: + return ( + target_timestamp > start_timestamp and target_timestamp < end_timestamp + ) + + # math + + def __add__(self, other): + + if isinstance(other, (timedelta, relativedelta)): + return self.fromdatetime(self._datetime + other, self._datetime.tzinfo) + + return NotImplemented + + def __radd__(self, other): + return self.__add__(other) + + def __sub__(self, other): + + if isinstance(other, (timedelta, relativedelta)): + return self.fromdatetime(self._datetime - other, self._datetime.tzinfo) + + elif isinstance(other, datetime): + return self._datetime - other + + elif isinstance(other, Arrow): + return self._datetime - other._datetime + + return NotImplemented + + def __rsub__(self, other): + + if isinstance(other, datetime): + return other - self._datetime + + return NotImplemented + + # comparisons + + def __eq__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return False + + return self._datetime == self._get_datetime(other) + + def __ne__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return True + + return not self.__eq__(other) + + def __gt__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return NotImplemented + + return self._datetime > self._get_datetime(other) + + def __ge__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return NotImplemented + + return self._datetime >= self._get_datetime(other) + + def __lt__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return NotImplemented + + return self._datetime < self._get_datetime(other) + + def __le__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return NotImplemented + + return self._datetime <= self._get_datetime(other) + + def __cmp__(self, other): + if sys.version_info[0] < 3: # pragma: no cover + if not isinstance(other, (Arrow, datetime)): + raise TypeError( + "can't compare '{}' to '{}'".format(type(self), type(other)) + ) + + # datetime methods + + def date(self): + """ Returns a ``date`` object with the same year, month and day. + + Usage:: + + >>> arrow.utcnow().date() + datetime.date(2019, 1, 23) + + """ + + return self._datetime.date() + + def time(self): + """ Returns a ``time`` object with the same hour, minute, second, microsecond. + + Usage:: + + >>> arrow.utcnow().time() + datetime.time(12, 15, 34, 68352) + + """ + + return self._datetime.time() + + def timetz(self): + """ Returns a ``time`` object with the same hour, minute, second, microsecond and + tzinfo. + + Usage:: + + >>> arrow.utcnow().timetz() + datetime.time(12, 5, 18, 298893, tzinfo=tzutc()) + + """ + + return self._datetime.timetz() + + def astimezone(self, tz): + """ Returns a ``datetime`` object, converted to the specified timezone. + + :param tz: a ``tzinfo`` object. + + Usage:: + + >>> pacific=arrow.now('US/Pacific') + >>> nyc=arrow.now('America/New_York').tzinfo + >>> pacific.astimezone(nyc) + datetime.datetime(2019, 1, 20, 10, 24, 22, 328172, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York')) + + """ + + return self._datetime.astimezone(tz) + + def utcoffset(self): + """ Returns a ``timedelta`` object representing the whole number of minutes difference from + UTC time. + + Usage:: + + >>> arrow.now('US/Pacific').utcoffset() + datetime.timedelta(-1, 57600) + + """ + + return self._datetime.utcoffset() + + def dst(self): + """ Returns the daylight savings time adjustment. + + Usage:: + + >>> arrow.utcnow().dst() + datetime.timedelta(0) + + """ + + return self._datetime.dst() + + def timetuple(self): + """ Returns a ``time.struct_time``, in the current timezone. + + Usage:: + + >>> arrow.utcnow().timetuple() + time.struct_time(tm_year=2019, tm_mon=1, tm_mday=20, tm_hour=15, tm_min=17, tm_sec=8, tm_wday=6, tm_yday=20, tm_isdst=0) + + """ + + return self._datetime.timetuple() + + def utctimetuple(self): + """ Returns a ``time.struct_time``, in UTC time. + + Usage:: + + >>> arrow.utcnow().utctimetuple() + time.struct_time(tm_year=2019, tm_mon=1, tm_mday=19, tm_hour=21, tm_min=41, tm_sec=7, tm_wday=5, tm_yday=19, tm_isdst=0) + + """ + + return self._datetime.utctimetuple() + + def toordinal(self): + """ Returns the proleptic Gregorian ordinal of the date. + + Usage:: + + >>> arrow.utcnow().toordinal() + 737078 + + """ + + return self._datetime.toordinal() + + def weekday(self): + """ Returns the day of the week as an integer (0-6). + + Usage:: + + >>> arrow.utcnow().weekday() + 5 + + """ + + return self._datetime.weekday() + + def isoweekday(self): + """ Returns the ISO day of the week as an integer (1-7). + + Usage:: + + >>> arrow.utcnow().isoweekday() + 6 + + """ + + return self._datetime.isoweekday() + + def isocalendar(self): + """ Returns a 3-tuple, (ISO year, ISO week number, ISO weekday). + + Usage:: + + >>> arrow.utcnow().isocalendar() + (2019, 3, 6) + + """ + + return self._datetime.isocalendar() + + def isoformat(self, sep="T"): + """Returns an ISO 8601 formatted representation of the date and time. + + Usage:: + + >>> arrow.utcnow().isoformat() + '2019-01-19T18:30:52.442118+00:00' + + """ + + return self._datetime.isoformat(sep) + + def ctime(self): + """ Returns a ctime formatted representation of the date and time. + + Usage:: + + >>> arrow.utcnow().ctime() + 'Sat Jan 19 18:26:50 2019' + + """ + + return self._datetime.ctime() + + def strftime(self, format): + """ Formats in the style of ``datetime.strftime``. + + :param format: the format string. + + Usage:: + + >>> arrow.utcnow().strftime('%d-%m-%Y %H:%M:%S') + '23-01-2019 12:28:17' + + """ + + return self._datetime.strftime(format) + + def for_json(self): + """Serializes for the ``for_json`` protocol of simplejson. + + Usage:: + + >>> arrow.utcnow().for_json() + '2019-01-19T18:25:36.760079+00:00' + + """ + + return self.isoformat() + + # internal tools. + + @staticmethod + def _get_tzinfo(tz_expr): + + if tz_expr is None: + return dateutil_tz.tzutc() + if isinstance(tz_expr, dt_tzinfo): + return tz_expr + else: + try: + return parser.TzinfoParser.parse(tz_expr) + except parser.ParserError: + raise ValueError("'{}' not recognized as a timezone".format(tz_expr)) + + @classmethod + def _get_datetime(cls, expr): + """Get datetime object for a specified expression.""" + if isinstance(expr, Arrow): + return expr.datetime + elif isinstance(expr, datetime): + return expr + elif util.is_timestamp(expr): + timestamp = float(expr) + return cls.utcfromtimestamp(timestamp).datetime + else: + raise ValueError( + "'{}' not recognized as a datetime or timestamp.".format(expr) + ) + + @classmethod + def _get_frames(cls, name): + + if name in cls._ATTRS: + return name, "{}s".format(name), 1 + elif name[-1] == "s" and name[:-1] in cls._ATTRS: + return name[:-1], name, 1 + elif name in ["week", "weeks"]: + return "week", "weeks", 1 + elif name in ["quarter", "quarters"]: + return "quarter", "months", 3 + + supported = ", ".join( + [ + "year(s)", + "month(s)", + "day(s)", + "hour(s)", + "minute(s)", + "second(s)", + "microsecond(s)", + "week(s)", + "quarter(s)", + ] + ) + raise AttributeError( + "range/span over frame {} not supported. Supported frames: {}".format( + name, supported + ) + ) + + @classmethod + def _get_iteration_params(cls, end, limit): + + if end is None: + + if limit is None: + raise ValueError("one of 'end' or 'limit' is required") + + return cls.max, limit + + else: + if limit is None: + return end, sys.maxsize + return end, limit + + +Arrow.min = Arrow.fromdatetime(datetime.min) +Arrow.max = Arrow.fromdatetime(datetime.max) diff --git a/script.module.slyguy/resources/modules/arrow/constants.py b/script.module.slyguy/resources/modules/arrow/constants.py new file mode 100644 index 00000000..81e37b26 --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/constants.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +# Output of time.mktime(datetime.max.timetuple()) on macOS +# This value must be hardcoded for compatibility with Windows +# Platform-independent max timestamps are hard to form +# https://stackoverflow.com/q/46133223 +MAX_TIMESTAMP = 253402318799.0 +MAX_TIMESTAMP_MS = MAX_TIMESTAMP * 1000 +MAX_TIMESTAMP_US = MAX_TIMESTAMP * 1000000 diff --git a/script.module.slyguy/resources/modules/arrow/factory.py b/script.module.slyguy/resources/modules/arrow/factory.py new file mode 100644 index 00000000..bf9e1f3a --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/factory.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +""" +Implements the :class:`ArrowFactory ` class, +providing factory methods for common :class:`Arrow ` +construction scenarios. + +""" + +from __future__ import absolute_import + +import calendar +from datetime import date, datetime +from datetime import tzinfo as dt_tzinfo +from time import struct_time + +from dateutil import tz as dateutil_tz + +from arrow import parser +from arrow.arrow import Arrow +from arrow.util import is_timestamp, iso_to_gregorian, isstr + + +class ArrowFactory(object): + """ A factory for generating :class:`Arrow ` objects. + + :param type: (optional) the :class:`Arrow `-based class to construct from. + Defaults to :class:`Arrow `. + + """ + + def __init__(self, type=Arrow): + self.type = type + + def get(self, *args, **kwargs): + """ Returns an :class:`Arrow ` object based on flexible inputs. + + :param locale: (optional) a ``str`` specifying a locale for the parser. Defaults to + 'en_us'. + :param tzinfo: (optional) a :ref:`timezone expression ` or tzinfo object. + Replaces the timezone unless using an input form that is explicitly UTC or specifies + the timezone in a positional argument. Defaults to UTC. + + Usage:: + + >>> import arrow + + **No inputs** to get current UTC time:: + + >>> arrow.get() + + + **None** to also get current UTC time:: + + >>> arrow.get(None) + + + **One** :class:`Arrow ` object, to get a copy. + + >>> arw = arrow.utcnow() + >>> arrow.get(arw) + + + **One** ``float`` or ``int``, convertible to a floating-point timestamp, to get + that timestamp in UTC:: + + >>> arrow.get(1367992474.293378) + + + >>> arrow.get(1367992474) + + + **One** ISO 8601-formatted ``str``, to parse it:: + + >>> arrow.get('2013-09-29T01:26:43.830580') + + + **One** ISO 8601-formatted ``str``, in basic format, to parse it:: + + >>> arrow.get('20160413T133656.456289') + + + **One** ``tzinfo``, to get the current time **converted** to that timezone:: + + >>> arrow.get(tz.tzlocal()) + + + **One** naive ``datetime``, to get that datetime in UTC:: + + >>> arrow.get(datetime(2013, 5, 5)) + + + **One** aware ``datetime``, to get that datetime:: + + >>> arrow.get(datetime(2013, 5, 5, tzinfo=tz.tzlocal())) + + + **One** naive ``date``, to get that date in UTC:: + + >>> arrow.get(date(2013, 5, 5)) + + + **One** time.struct time:: + + >>> arrow.get(gmtime(0)) + + + **One** iso calendar ``tuple``, to get that week date in UTC:: + + >>> arrow.get((2013, 18, 7)) + + + **Two** arguments, a naive or aware ``datetime``, and a replacement + :ref:`timezone expression `:: + + >>> arrow.get(datetime(2013, 5, 5), 'US/Pacific') + + + **Two** arguments, a naive ``date``, and a replacement + :ref:`timezone expression `:: + + >>> arrow.get(date(2013, 5, 5), 'US/Pacific') + + + **Two** arguments, both ``str``, to parse the first according to the format of the second:: + + >>> arrow.get('2013-05-05 12:30:45 America/Chicago', 'YYYY-MM-DD HH:mm:ss ZZZ') + + + **Two** arguments, first a ``str`` to parse and second a ``list`` of formats to try:: + + >>> arrow.get('2013-05-05 12:30:45', ['MM/DD/YYYY', 'YYYY-MM-DD HH:mm:ss']) + + + **Three or more** arguments, as for the constructor of a ``datetime``:: + + >>> arrow.get(2013, 5, 5, 12, 30, 45) + + + """ + + arg_count = len(args) + locale = kwargs.pop("locale", "en_us") + tz = kwargs.get("tzinfo", None) + + # if kwargs given, send to constructor unless only tzinfo provided + if len(kwargs) > 1: + arg_count = 3 + + # tzinfo kwarg is not provided + if len(kwargs) == 1 and tz is None: + arg_count = 3 + + # () -> now, @ utc. + if arg_count == 0: + if isstr(tz): + tz = parser.TzinfoParser.parse(tz) + return self.type.now(tz) + + if isinstance(tz, dt_tzinfo): + return self.type.now(tz) + + return self.type.utcnow() + + if arg_count == 1: + arg = args[0] + + # (None) -> now, @ utc. + if arg is None: + return self.type.utcnow() + + # try (int, float) -> from timestamp with tz + elif not isstr(arg) and is_timestamp(arg): + if tz is None: + # set to UTC by default + tz = dateutil_tz.tzutc() + return self.type.fromtimestamp(arg, tzinfo=tz) + + # (Arrow) -> from the object's datetime. + elif isinstance(arg, Arrow): + return self.type.fromdatetime(arg.datetime) + + # (datetime) -> from datetime. + elif isinstance(arg, datetime): + return self.type.fromdatetime(arg) + + # (date) -> from date. + elif isinstance(arg, date): + return self.type.fromdate(arg) + + # (tzinfo) -> now, @ tzinfo. + elif isinstance(arg, dt_tzinfo): + return self.type.now(arg) + + # (str) -> parse. + elif isstr(arg): + dt = parser.DateTimeParser(locale).parse_iso(arg) + return self.type.fromdatetime(dt, tz) + + # (struct_time) -> from struct_time + elif isinstance(arg, struct_time): + return self.type.utcfromtimestamp(calendar.timegm(arg)) + + # (iso calendar) -> convert then from date + elif isinstance(arg, tuple) and len(arg) == 3: + dt = iso_to_gregorian(*arg) + return self.type.fromdate(dt) + + else: + raise TypeError( + "Can't parse single argument of type '{}'".format(type(arg)) + ) + + elif arg_count == 2: + + arg_1, arg_2 = args[0], args[1] + + if isinstance(arg_1, datetime): + + # (datetime, tzinfo/str) -> fromdatetime replace tzinfo. + if isinstance(arg_2, dt_tzinfo) or isstr(arg_2): + return self.type.fromdatetime(arg_1, arg_2) + else: + raise TypeError( + "Can't parse two arguments of types 'datetime', '{}'".format( + type(arg_2) + ) + ) + + elif isinstance(arg_1, date): + + # (date, tzinfo/str) -> fromdate replace tzinfo. + if isinstance(arg_2, dt_tzinfo) or isstr(arg_2): + return self.type.fromdate(arg_1, tzinfo=arg_2) + else: + raise TypeError( + "Can't parse two arguments of types 'date', '{}'".format( + type(arg_2) + ) + ) + + # (str, format) -> parse. + elif isstr(arg_1) and (isstr(arg_2) or isinstance(arg_2, list)): + dt = parser.DateTimeParser(locale).parse(args[0], args[1]) + return self.type.fromdatetime(dt, tzinfo=tz) + + else: + raise TypeError( + "Can't parse two arguments of types '{}' and '{}'".format( + type(arg_1), type(arg_2) + ) + ) + + # 3+ args -> datetime-like via constructor. + else: + return self.type(*args, **kwargs) + + def utcnow(self): + """Returns an :class:`Arrow ` object, representing "now" in UTC time. + + Usage:: + + >>> import arrow + >>> arrow.utcnow() + + """ + + return self.type.utcnow() + + def now(self, tz=None): + """Returns an :class:`Arrow ` object, representing "now" in the given + timezone. + + :param tz: (optional) A :ref:`timezone expression `. Defaults to local time. + + Usage:: + + >>> import arrow + >>> arrow.now() + + + >>> arrow.now('US/Pacific') + + + >>> arrow.now('+02:00') + + + >>> arrow.now('local') + + """ + + if tz is None: + tz = dateutil_tz.tzlocal() + elif not isinstance(tz, dt_tzinfo): + tz = parser.TzinfoParser.parse(tz) + + return self.type.now(tz) diff --git a/script.module.slyguy/resources/modules/arrow/formatter.py b/script.module.slyguy/resources/modules/arrow/formatter.py new file mode 100644 index 00000000..9f9d7a44 --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/formatter.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division + +import calendar +import re + +from dateutil import tz as dateutil_tz + +from arrow import locales, util + +FORMAT_ATOM = "YYYY-MM-DD HH:mm:ssZZ" +FORMAT_COOKIE = "dddd, DD-MMM-YYYY HH:mm:ss ZZZ" +FORMAT_RFC822 = "ddd, DD MMM YY HH:mm:ss Z" +FORMAT_RFC850 = "dddd, DD-MMM-YY HH:mm:ss ZZZ" +FORMAT_RFC1036 = "ddd, DD MMM YY HH:mm:ss Z" +FORMAT_RFC1123 = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_RFC2822 = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_RFC3339 = "YYYY-MM-DD HH:mm:ssZZ" +FORMAT_RSS = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_W3C = "YYYY-MM-DD HH:mm:ssZZ" + + +class DateTimeFormatter(object): + + # This pattern matches characters enclosed in square brackets are matched as + # an atomic group. For more info on atomic groups and how to they are + # emulated in Python's re library, see https://stackoverflow.com/a/13577411/2701578 + + _FORMAT_RE = re.compile( + r"(\[(?:(?=(?P[^]]))(?P=literal))*\]|YYY?Y?|MM?M?M?|Do|DD?D?D?|d?dd?d?|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?Z?|a|A|X|x|W)" + ) + + def __init__(self, locale="en_us"): + + self.locale = locales.get_locale(locale) + + def format(cls, dt, fmt): + + return cls._FORMAT_RE.sub(lambda m: cls._format_token(dt, m.group(0)), fmt) + + def _format_token(self, dt, token): + + if token and token.startswith("[") and token.endswith("]"): + return token[1:-1] + + if token == "YYYY": + return self.locale.year_full(dt.year) + if token == "YY": + return self.locale.year_abbreviation(dt.year) + + if token == "MMMM": + return self.locale.month_name(dt.month) + if token == "MMM": + return self.locale.month_abbreviation(dt.month) + if token == "MM": + return "{:02d}".format(dt.month) + if token == "M": + return str(dt.month) + + if token == "DDDD": + return "{:03d}".format(dt.timetuple().tm_yday) + if token == "DDD": + return str(dt.timetuple().tm_yday) + if token == "DD": + return "{:02d}".format(dt.day) + if token == "D": + return str(dt.day) + + if token == "Do": + return self.locale.ordinal_number(dt.day) + + if token == "dddd": + return self.locale.day_name(dt.isoweekday()) + if token == "ddd": + return self.locale.day_abbreviation(dt.isoweekday()) + if token == "d": + return str(dt.isoweekday()) + + if token == "HH": + return "{:02d}".format(dt.hour) + if token == "H": + return str(dt.hour) + if token == "hh": + return "{:02d}".format(dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12)) + if token == "h": + return str(dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12)) + + if token == "mm": + return "{:02d}".format(dt.minute) + if token == "m": + return str(dt.minute) + + if token == "ss": + return "{:02d}".format(dt.second) + if token == "s": + return str(dt.second) + + if token == "SSSSSS": + return str("{:06d}".format(int(dt.microsecond))) + if token == "SSSSS": + return str("{:05d}".format(int(dt.microsecond / 10))) + if token == "SSSS": + return str("{:04d}".format(int(dt.microsecond / 100))) + if token == "SSS": + return str("{:03d}".format(int(dt.microsecond / 1000))) + if token == "SS": + return str("{:02d}".format(int(dt.microsecond / 10000))) + if token == "S": + return str(int(dt.microsecond / 100000)) + + if token == "X": + # TODO: replace with a call to dt.timestamp() when we drop Python 2.7 + return str(calendar.timegm(dt.utctimetuple())) + + if token == "x": + # TODO: replace with a call to dt.timestamp() when we drop Python 2.7 + ts = calendar.timegm(dt.utctimetuple()) + (dt.microsecond / 1000000) + return str(int(ts * 1000000)) + + if token == "ZZZ": + return dt.tzname() + + if token in ["ZZ", "Z"]: + separator = ":" if token == "ZZ" else "" + tz = dateutil_tz.tzutc() if dt.tzinfo is None else dt.tzinfo + total_minutes = int(util.total_seconds(tz.utcoffset(dt)) / 60) + + sign = "+" if total_minutes >= 0 else "-" + total_minutes = abs(total_minutes) + hour, minute = divmod(total_minutes, 60) + + return "{}{:02d}{}{:02d}".format(sign, hour, separator, minute) + + if token in ("a", "A"): + return self.locale.meridian(dt.hour, token) + + if token == "W": + year, week, day = dt.isocalendar() + return "{}-W{:02d}-{}".format(year, week, day) diff --git a/script.module.slyguy/resources/modules/arrow/locales.py b/script.module.slyguy/resources/modules/arrow/locales.py new file mode 100644 index 00000000..b453f82c --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/locales.py @@ -0,0 +1,4261 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +import inspect +import sys +from math import trunc + + +def get_locale(name): + """Returns an appropriate :class:`Locale ` + corresponding to an inpute locale name. + + :param name: the name of the locale. + + """ + + locale_cls = _locales.get(name.lower()) + + if locale_cls is None: + raise ValueError("Unsupported locale '{}'".format(name)) + + return locale_cls() + + +def get_locale_by_class_name(name): + """Returns an appropriate :class:`Locale ` + corresponding to an locale class name. + + :param name: the name of the locale class. + + """ + locale_cls = globals().get(name) + + if locale_cls is None: + raise ValueError("Unsupported locale '{}'".format(name)) + + return locale_cls() + + +# base locale type. + + +class Locale(object): + """ Represents locale-specific data and functionality. """ + + names = [] + + timeframes = { + "now": "", + "second": "", + "seconds": "", + "minute": "", + "minutes": "", + "hour": "", + "hours": "", + "day": "", + "days": "", + "week": "", + "weeks": "", + "month": "", + "months": "", + "year": "", + "years": "", + } + + meridians = {"am": "", "pm": "", "AM": "", "PM": ""} + + past = None + future = None + and_word = None + + month_names = [] + month_abbreviations = [] + + day_names = [] + day_abbreviations = [] + + ordinal_day_re = r"(\d+)" + + def __init__(self): + + self._month_name_to_ordinal = None + + def describe(self, timeframe, delta=0, only_distance=False): + """ Describes a delta within a timeframe in plain language. + + :param timeframe: a string representing a timeframe. + :param delta: a quantity representing a delta in a timeframe. + :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords + """ + + humanized = self._format_timeframe(timeframe, delta) + if not only_distance: + humanized = self._format_relative(humanized, timeframe, delta) + + return humanized + + def describe_multi(self, timeframes, only_distance=False): + """ Describes a delta within multiple timeframes in plain language. + + :param timeframes: a list of string, quantity pairs each representing a timeframe and delta. + :param only_distance: return only distance eg: "2 hours and 11 seconds" without "in" or "ago" keywords + """ + + humanized = "" + for index, (timeframe, delta) in enumerate(timeframes): + humanized += self._format_timeframe(timeframe, delta) + if index == len(timeframes) - 2 and self.and_word: + humanized += " " + self.and_word + " " + elif index < len(timeframes) - 1: + humanized += " " + + if not only_distance: + humanized = self._format_relative(humanized, timeframe, delta) + + return humanized + + def day_name(self, day): + """ Returns the day name for a specified day of the week. + + :param day: the ``int`` day of the week (1-7). + + """ + + return self.day_names[day] + + def day_abbreviation(self, day): + """ Returns the day abbreviation for a specified day of the week. + + :param day: the ``int`` day of the week (1-7). + + """ + + return self.day_abbreviations[day] + + def month_name(self, month): + """ Returns the month name for a specified month of the year. + + :param month: the ``int`` month of the year (1-12). + + """ + + return self.month_names[month] + + def month_abbreviation(self, month): + """ Returns the month abbreviation for a specified month of the year. + + :param month: the ``int`` month of the year (1-12). + + """ + + return self.month_abbreviations[month] + + def month_number(self, name): + """ Returns the month number for a month specified by name or abbreviation. + + :param name: the month name or abbreviation. + + """ + + if self._month_name_to_ordinal is None: + self._month_name_to_ordinal = self._name_to_ordinal(self.month_names) + self._month_name_to_ordinal.update( + self._name_to_ordinal(self.month_abbreviations) + ) + + return self._month_name_to_ordinal.get(name) + + def year_full(self, year): + """ Returns the year for specific locale if available + + :param name: the ``int`` year (4-digit) + """ + return "{:04d}".format(year) + + def year_abbreviation(self, year): + """ Returns the year for specific locale if available + + :param name: the ``int`` year (4-digit) + """ + return "{:04d}".format(year)[2:] + + def meridian(self, hour, token): + """ Returns the meridian indicator for a specified hour and format token. + + :param hour: the ``int`` hour of the day. + :param token: the format token. + """ + + if token == "a": + return self.meridians["am"] if hour < 12 else self.meridians["pm"] + if token == "A": + return self.meridians["AM"] if hour < 12 else self.meridians["PM"] + + def ordinal_number(self, n): + """ Returns the ordinal format of a given integer + + :param n: an integer + """ + return self._ordinal_number(n) + + def _ordinal_number(self, n): + return "{}".format(n) + + def _name_to_ordinal(self, lst): + return dict(map(lambda i: (i[1].lower(), i[0] + 1), enumerate(lst[1:]))) + + def _format_timeframe(self, timeframe, delta): + return self.timeframes[timeframe].format(trunc(abs(delta))) + + def _format_relative(self, humanized, timeframe, delta): + + if timeframe == "now": + return humanized + + direction = self.past if delta < 0 else self.future + + return direction.format(humanized) + + +# base locale type implementations. + + +class EnglishLocale(Locale): + + names = [ + "en", + "en_us", + "en_gb", + "en_au", + "en_be", + "en_jp", + "en_za", + "en_ca", + "en_ph", + ] + + past = "{0} ago" + future = "in {0}" + and_word = "and" + + timeframes = { + "now": "just now", + "second": "a second", + "seconds": "{0} seconds", + "minute": "a minute", + "minutes": "{0} minutes", + "hour": "an hour", + "hours": "{0} hours", + "day": "a day", + "days": "{0} days", + "week": "a week", + "weeks": "{0} weeks", + "month": "a month", + "months": "{0} months", + "year": "a year", + "years": "{0} years", + } + + meridians = {"am": "am", "pm": "pm", "AM": "AM", "PM": "PM"} + + month_names = [ + "", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ] + + day_names = [ + "", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] + day_abbreviations = ["", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + ordinal_day_re = r"((?P[2-3]?1(?=st)|[2-3]?2(?=nd)|[2-3]?3(?=rd)|[1-3]?[04-9](?=th)|1[1-3](?=th))(st|nd|rd|th))" + + def _ordinal_number(self, n): + if n % 100 not in (11, 12, 13): + remainder = abs(n) % 10 + if remainder == 1: + return "{}st".format(n) + elif remainder == 2: + return "{}nd".format(n) + elif remainder == 3: + return "{}rd".format(n) + return "{}th".format(n) + + def describe(self, timeframe, delta=0, only_distance=False): + """ Describes a delta within a timeframe in plain language. + + :param timeframe: a string representing a timeframe. + :param delta: a quantity representing a delta in a timeframe. + :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords + """ + + humanized = super(EnglishLocale, self).describe(timeframe, delta, only_distance) + if only_distance and timeframe == "now": + humanized = "instantly" + + return humanized + + +class ItalianLocale(Locale): + names = ["it", "it_it"] + past = "{0} fa" + future = "tra {0}" + and_word = "e" + + timeframes = { + "now": "adesso", + "second": "un secondo", + "seconds": "{0} qualche secondo", + "minute": "un minuto", + "minutes": "{0} minuti", + "hour": "un'ora", + "hours": "{0} ore", + "day": "un giorno", + "days": "{0} giorni", + "week": "una settimana,", + "weeks": "{0} settimane", + "month": "un mese", + "months": "{0} mesi", + "year": "un anno", + "years": "{0} anni", + } + + month_names = [ + "", + "gennaio", + "febbraio", + "marzo", + "aprile", + "maggio", + "giugno", + "luglio", + "agosto", + "settembre", + "ottobre", + "novembre", + "dicembre", + ] + month_abbreviations = [ + "", + "gen", + "feb", + "mar", + "apr", + "mag", + "giu", + "lug", + "ago", + "set", + "ott", + "nov", + "dic", + ] + + day_names = [ + "", + "lunedì", + "martedì", + "mercoledì", + "giovedì", + "venerdì", + "sabato", + "domenica", + ] + day_abbreviations = ["", "lun", "mar", "mer", "gio", "ven", "sab", "dom"] + + ordinal_day_re = r"((?P[1-3]?[0-9](?=[ºª]))[ºª])" + + def _ordinal_number(self, n): + return "{}º".format(n) + + +class SpanishLocale(Locale): + names = ["es", "es_es"] + past = "hace {0}" + future = "en {0}" + and_word = "y" + + timeframes = { + "now": "ahora", + "second": "un segundo", + "seconds": "{0} segundos", + "minute": "un minuto", + "minutes": "{0} minutos", + "hour": "una hora", + "hours": "{0} horas", + "day": "un día", + "days": "{0} días", + "week": "una semana", + "weeks": "{0} semanas", + "month": "un mes", + "months": "{0} meses", + "year": "un año", + "years": "{0} años", + } + + meridians = {"am": "am", "pm": "pm", "AM": "AM", "PM": "PM"} + + month_names = [ + "", + "enero", + "febrero", + "marzo", + "abril", + "mayo", + "junio", + "julio", + "agosto", + "septiembre", + "octubre", + "noviembre", + "diciembre", + ] + month_abbreviations = [ + "", + "ene", + "feb", + "mar", + "abr", + "may", + "jun", + "jul", + "ago", + "sep", + "oct", + "nov", + "dic", + ] + + day_names = [ + "", + "lunes", + "martes", + "miércoles", + "jueves", + "viernes", + "sábado", + "domingo", + ] + day_abbreviations = ["", "lun", "mar", "mie", "jue", "vie", "sab", "dom"] + + ordinal_day_re = r"((?P[1-3]?[0-9](?=[ºª]))[ºª])" + + def _ordinal_number(self, n): + return "{}º".format(n) + + +class FrenchBaseLocale(Locale): + + past = "il y a {0}" + future = "dans {0}" + and_word = "et" + + timeframes = { + "now": "maintenant", + "second": "une seconde", + "seconds": "{0} quelques secondes", + "minute": "une minute", + "minutes": "{0} minutes", + "hour": "une heure", + "hours": "{0} heures", + "day": "un jour", + "days": "{0} jours", + "week": "une semaine", + "weeks": "{0} semaines", + "month": "un mois", + "months": "{0} mois", + "year": "un an", + "years": "{0} ans", + } + + month_names = [ + "", + "janvier", + "février", + "mars", + "avril", + "mai", + "juin", + "juillet", + "août", + "septembre", + "octobre", + "novembre", + "décembre", + ] + + day_names = [ + "", + "lundi", + "mardi", + "mercredi", + "jeudi", + "vendredi", + "samedi", + "dimanche", + ] + day_abbreviations = ["", "lun", "mar", "mer", "jeu", "ven", "sam", "dim"] + + ordinal_day_re = ( + r"((?P\b1(?=er\b)|[1-3]?[02-9](?=e\b)|[1-3]1(?=e\b))(er|e)\b)" + ) + + def _ordinal_number(self, n): + if abs(n) == 1: + return "{}er".format(n) + return "{}e".format(n) + + +class FrenchLocale(FrenchBaseLocale, Locale): + + names = ["fr", "fr_fr"] + + month_abbreviations = [ + "", + "janv", + "févr", + "mars", + "avr", + "mai", + "juin", + "juil", + "août", + "sept", + "oct", + "nov", + "déc", + ] + + +class FrenchCanadianLocale(FrenchBaseLocale, Locale): + + names = ["fr_ca"] + + month_abbreviations = [ + "", + "janv", + "févr", + "mars", + "avr", + "mai", + "juin", + "juill", + "août", + "sept", + "oct", + "nov", + "déc", + ] + + +class GreekLocale(Locale): + + names = ["el", "el_gr"] + + past = "{0} πριν" + future = "σε {0}" + and_word = "και" + + timeframes = { + "now": "τώρα", + "second": "ένα δεύτερο", + "seconds": "{0} δευτερόλεπτα", + "minute": "ένα λεπτό", + "minutes": "{0} λεπτά", + "hour": "μία ώρα", + "hours": "{0} ώρες", + "day": "μία μέρα", + "days": "{0} μέρες", + "month": "ένα μήνα", + "months": "{0} μήνες", + "year": "ένα χρόνο", + "years": "{0} χρόνια", + } + + month_names = [ + "", + "Ιανουαρίου", + "Φεβρουαρίου", + "Μαρτίου", + "Απριλίου", + "Μαΐου", + "Ιουνίου", + "Ιουλίου", + "Αυγούστου", + "Σεπτεμβρίου", + "Οκτωβρίου", + "Νοεμβρίου", + "Δεκεμβρίου", + ] + month_abbreviations = [ + "", + "Ιαν", + "Φεβ", + "Μαρ", + "Απρ", + "Μαϊ", + "Ιον", + "Ιολ", + "Αυγ", + "Σεπ", + "Οκτ", + "Νοε", + "Δεκ", + ] + + day_names = [ + "", + "Δευτέρα", + "Τρίτη", + "Τετάρτη", + "Πέμπτη", + "Παρασκευή", + "Σάββατο", + "Κυριακή", + ] + day_abbreviations = ["", "Δευ", "Τρι", "Τετ", "Πεμ", "Παρ", "Σαβ", "Κυρ"] + + +class JapaneseLocale(Locale): + + names = ["ja", "ja_jp"] + + past = "{0}前" + future = "{0}後" + + timeframes = { + "now": "現在", + "second": "二番目の", + "seconds": "{0}数秒", + "minute": "1分", + "minutes": "{0}分", + "hour": "1時間", + "hours": "{0}時間", + "day": "1日", + "days": "{0}日", + "week": "1週間", + "weeks": "{0}週間", + "month": "1ヶ月", + "months": "{0}ヶ月", + "year": "1年", + "years": "{0}年", + } + + month_names = [ + "", + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日", "日曜日"] + day_abbreviations = ["", "月", "火", "水", "木", "金", "土", "日"] + + +class SwedishLocale(Locale): + + names = ["sv", "sv_se"] + + past = "för {0} sen" + future = "om {0}" + and_word = "och" + + timeframes = { + "now": "just nu", + "second": "en sekund", + "seconds": "{0} några sekunder", + "minute": "en minut", + "minutes": "{0} minuter", + "hour": "en timme", + "hours": "{0} timmar", + "day": "en dag", + "days": "{0} dagar", + "week": "en vecka", + "weeks": "{0} veckor", + "month": "en månad", + "months": "{0} månader", + "year": "ett år", + "years": "{0} år", + } + + month_names = [ + "", + "januari", + "februari", + "mars", + "april", + "maj", + "juni", + "juli", + "augusti", + "september", + "oktober", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "maj", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "måndag", + "tisdag", + "onsdag", + "torsdag", + "fredag", + "lördag", + "söndag", + ] + day_abbreviations = ["", "mån", "tis", "ons", "tor", "fre", "lör", "sön"] + + +class FinnishLocale(Locale): + + names = ["fi", "fi_fi"] + + # The finnish grammar is very complex, and its hard to convert + # 1-to-1 to something like English. + + past = "{0} sitten" + future = "{0} kuluttua" + + timeframes = { + "now": ["juuri nyt", "juuri nyt"], + "second": ["sekunti", "sekunti"], + "seconds": ["{0} muutama sekunti", "{0} muutaman sekunnin"], + "minute": ["minuutti", "minuutin"], + "minutes": ["{0} minuuttia", "{0} minuutin"], + "hour": ["tunti", "tunnin"], + "hours": ["{0} tuntia", "{0} tunnin"], + "day": ["päivä", "päivä"], + "days": ["{0} päivää", "{0} päivän"], + "month": ["kuukausi", "kuukauden"], + "months": ["{0} kuukautta", "{0} kuukauden"], + "year": ["vuosi", "vuoden"], + "years": ["{0} vuotta", "{0} vuoden"], + } + + # Months and days are lowercase in Finnish + month_names = [ + "", + "tammikuu", + "helmikuu", + "maaliskuu", + "huhtikuu", + "toukokuu", + "kesäkuu", + "heinäkuu", + "elokuu", + "syyskuu", + "lokakuu", + "marraskuu", + "joulukuu", + ] + + month_abbreviations = [ + "", + "tammi", + "helmi", + "maalis", + "huhti", + "touko", + "kesä", + "heinä", + "elo", + "syys", + "loka", + "marras", + "joulu", + ] + + day_names = [ + "", + "maanantai", + "tiistai", + "keskiviikko", + "torstai", + "perjantai", + "lauantai", + "sunnuntai", + ] + + day_abbreviations = ["", "ma", "ti", "ke", "to", "pe", "la", "su"] + + def _format_timeframe(self, timeframe, delta): + return ( + self.timeframes[timeframe][0].format(abs(delta)), + self.timeframes[timeframe][1].format(abs(delta)), + ) + + def _format_relative(self, humanized, timeframe, delta): + if timeframe == "now": + return humanized[0] + + direction = self.past if delta < 0 else self.future + which = 0 if delta < 0 else 1 + + return direction.format(humanized[which]) + + def _ordinal_number(self, n): + return "{}.".format(n) + + +class ChineseCNLocale(Locale): + + names = ["zh", "zh_cn"] + + past = "{0}前" + future = "{0}后" + + timeframes = { + "now": "刚才", + "second": "一秒", + "seconds": "{0}秒", + "minute": "1分钟", + "minutes": "{0}分钟", + "hour": "1小时", + "hours": "{0}小时", + "day": "1天", + "days": "{0}天", + "week": "一周", + "weeks": "{0}周", + "month": "1个月", + "months": "{0}个月", + "year": "1年", + "years": "{0}年", + } + + month_names = [ + "", + "一月", + "二月", + "三月", + "四月", + "五月", + "六月", + "七月", + "八月", + "九月", + "十月", + "十一月", + "十二月", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] + day_abbreviations = ["", "一", "二", "三", "四", "五", "六", "日"] + + +class ChineseTWLocale(Locale): + + names = ["zh_tw"] + + past = "{0}前" + future = "{0}後" + and_word = "和" + + timeframes = { + "now": "剛才", + "second": "1秒", + "seconds": "{0}秒", + "minute": "1分鐘", + "minutes": "{0}分鐘", + "hour": "1小時", + "hours": "{0}小時", + "day": "1天", + "days": "{0}天", + "week": "1週", + "weeks": "{0}週", + "month": "1個月", + "months": "{0}個月", + "year": "1年", + "years": "{0}年", + } + + month_names = [ + "", + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "週一", "週二", "週三", "週四", "週五", "週六", "週日"] + day_abbreviations = ["", "一", "二", "三", "四", "五", "六", "日"] + + +class HongKongLocale(Locale): + + names = ["zh_hk"] + + past = "{0}前" + future = "{0}後" + + timeframes = { + "now": "剛才", + "second": "1秒", + "seconds": "{0}秒", + "minute": "1分鐘", + "minutes": "{0}分鐘", + "hour": "1小時", + "hours": "{0}小時", + "day": "1天", + "days": "{0}天", + "week": "1星期", + "weeks": "{0}星期", + "month": "1個月", + "months": "{0}個月", + "year": "1年", + "years": "{0}年", + } + + month_names = [ + "", + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] + day_abbreviations = ["", "一", "二", "三", "四", "五", "六", "日"] + + +class KoreanLocale(Locale): + + names = ["ko", "ko_kr"] + + past = "{0} 전" + future = "{0} 후" + + timeframes = { + "now": "지금", + "second": "1초", + "seconds": "{0}초", + "minute": "1분", + "minutes": "{0}분", + "hour": "한시간", + "hours": "{0}시간", + "day": "하루", + "days": "{0}일", + "week": "1주", + "weeks": "{0}주", + "month": "한달", + "months": "{0}개월", + "year": "1년", + "years": "{0}년", + } + + special_dayframes = { + -3: "그끄제", + -2: "그제", + -1: "어제", + 1: "내일", + 2: "모레", + 3: "글피", + 4: "그글피", + } + + special_yearframes = {-2: "제작년", -1: "작년", 1: "내년", 2: "내후년"} + + month_names = [ + "", + "1월", + "2월", + "3월", + "4월", + "5월", + "6월", + "7월", + "8월", + "9월", + "10월", + "11월", + "12월", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"] + day_abbreviations = ["", "월", "화", "수", "목", "금", "토", "일"] + + def _ordinal_number(self, n): + ordinals = ["0", "첫", "두", "세", "네", "다섯", "여섯", "일곱", "여덟", "아홉", "열"] + if n < len(ordinals): + return "{}번째".format(ordinals[n]) + return "{}번째".format(n) + + def _format_relative(self, humanized, timeframe, delta): + if timeframe in ("day", "days"): + special = self.special_dayframes.get(delta) + if special: + return special + elif timeframe in ("year", "years"): + special = self.special_yearframes.get(delta) + if special: + return special + + return super(KoreanLocale, self)._format_relative(humanized, timeframe, delta) + + +# derived locale types & implementations. +class DutchLocale(Locale): + + names = ["nl", "nl_nl"] + + past = "{0} geleden" + future = "over {0}" + + timeframes = { + "now": "nu", + "second": "een seconde", + "seconds": "{0} seconden", + "minute": "een minuut", + "minutes": "{0} minuten", + "hour": "een uur", + "hours": "{0} uur", + "day": "een dag", + "days": "{0} dagen", + "week": "een week", + "weeks": "{0} weken", + "month": "een maand", + "months": "{0} maanden", + "year": "een jaar", + "years": "{0} jaar", + } + + # In Dutch names of months and days are not starting with a capital letter + # like in the English language. + month_names = [ + "", + "januari", + "februari", + "maart", + "april", + "mei", + "juni", + "juli", + "augustus", + "september", + "oktober", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mrt", + "apr", + "mei", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "maandag", + "dinsdag", + "woensdag", + "donderdag", + "vrijdag", + "zaterdag", + "zondag", + ] + day_abbreviations = ["", "ma", "di", "wo", "do", "vr", "za", "zo"] + + +class SlavicBaseLocale(Locale): + def _format_timeframe(self, timeframe, delta): + + form = self.timeframes[timeframe] + delta = abs(delta) + + if isinstance(form, list): + + if delta % 10 == 1 and delta % 100 != 11: + form = form[0] + elif 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): + form = form[1] + else: + form = form[2] + + return form.format(delta) + + +class BelarusianLocale(SlavicBaseLocale): + + names = ["be", "be_by"] + + past = "{0} таму" + future = "праз {0}" + + timeframes = { + "now": "зараз", + "second": "секунду", + "seconds": "{0} некалькі секунд", + "minute": "хвіліну", + "minutes": ["{0} хвіліну", "{0} хвіліны", "{0} хвілін"], + "hour": "гадзіну", + "hours": ["{0} гадзіну", "{0} гадзіны", "{0} гадзін"], + "day": "дзень", + "days": ["{0} дзень", "{0} дні", "{0} дзён"], + "month": "месяц", + "months": ["{0} месяц", "{0} месяцы", "{0} месяцаў"], + "year": "год", + "years": ["{0} год", "{0} гады", "{0} гадоў"], + } + + month_names = [ + "", + "студзеня", + "лютага", + "сакавіка", + "красавіка", + "траўня", + "чэрвеня", + "ліпеня", + "жніўня", + "верасня", + "кастрычніка", + "лістапада", + "снежня", + ] + month_abbreviations = [ + "", + "студ", + "лют", + "сак", + "крас", + "трав", + "чэрв", + "ліп", + "жнів", + "вер", + "каст", + "ліст", + "снеж", + ] + + day_names = [ + "", + "панядзелак", + "аўторак", + "серада", + "чацвер", + "пятніца", + "субота", + "нядзеля", + ] + day_abbreviations = ["", "пн", "ат", "ср", "чц", "пт", "сб", "нд"] + + +class PolishLocale(SlavicBaseLocale): + + names = ["pl", "pl_pl"] + + past = "{0} temu" + future = "za {0}" + + # The nouns should be in genitive case (Polish: "dopełniacz") + # in order to correctly form `past` & `future` expressions. + timeframes = { + "now": "teraz", + "second": "sekundę", + "seconds": ["{0} sekund", "{0} sekundy", "{0} sekund"], + "minute": "minutę", + "minutes": ["{0} minut", "{0} minuty", "{0} minut"], + "hour": "godzinę", + "hours": ["{0} godzin", "{0} godziny", "{0} godzin"], + "day": "dzień", + "days": "{0} dni", + "week": "tydzień", + "weeks": ["{0} tygodni", "{0} tygodnie", "{0} tygodni"], + "month": "miesiąc", + "months": ["{0} miesięcy", "{0} miesiące", "{0} miesięcy"], + "year": "rok", + "years": ["{0} lat", "{0} lata", "{0} lat"], + } + + month_names = [ + "", + "styczeń", + "luty", + "marzec", + "kwiecień", + "maj", + "czerwiec", + "lipiec", + "sierpień", + "wrzesień", + "październik", + "listopad", + "grudzień", + ] + month_abbreviations = [ + "", + "sty", + "lut", + "mar", + "kwi", + "maj", + "cze", + "lip", + "sie", + "wrz", + "paź", + "lis", + "gru", + ] + + day_names = [ + "", + "poniedziałek", + "wtorek", + "środa", + "czwartek", + "piątek", + "sobota", + "niedziela", + ] + day_abbreviations = ["", "Pn", "Wt", "Śr", "Czw", "Pt", "So", "Nd"] + + +class RussianLocale(SlavicBaseLocale): + + names = ["ru", "ru_ru"] + + past = "{0} назад" + future = "через {0}" + + timeframes = { + "now": "сейчас", + "second": "Второй", + "seconds": "{0} несколько секунд", + "minute": "минуту", + "minutes": ["{0} минуту", "{0} минуты", "{0} минут"], + "hour": "час", + "hours": ["{0} час", "{0} часа", "{0} часов"], + "day": "день", + "days": ["{0} день", "{0} дня", "{0} дней"], + "week": "неделю", + "weeks": ["{0} неделю", "{0} недели", "{0} недель"], + "month": "месяц", + "months": ["{0} месяц", "{0} месяца", "{0} месяцев"], + "year": "год", + "years": ["{0} год", "{0} года", "{0} лет"], + } + + month_names = [ + "", + "января", + "февраля", + "марта", + "апреля", + "мая", + "июня", + "июля", + "августа", + "сентября", + "октября", + "ноября", + "декабря", + ] + month_abbreviations = [ + "", + "янв", + "фев", + "мар", + "апр", + "май", + "июн", + "июл", + "авг", + "сен", + "окт", + "ноя", + "дек", + ] + + day_names = [ + "", + "понедельник", + "вторник", + "среда", + "четверг", + "пятница", + "суббота", + "воскресенье", + ] + day_abbreviations = ["", "пн", "вт", "ср", "чт", "пт", "сб", "вс"] + + +class AfrikaansLocale(Locale): + + names = ["af", "af_nl"] + + past = "{0} gelede" + future = "in {0}" + + timeframes = { + "now": "nou", + "second": "n sekonde", + "seconds": "{0} sekondes", + "minute": "minuut", + "minutes": "{0} minute", + "hour": "uur", + "hours": "{0} ure", + "day": "een dag", + "days": "{0} dae", + "month": "een maand", + "months": "{0} maande", + "year": "een jaar", + "years": "{0} jaar", + } + + month_names = [ + "", + "Januarie", + "Februarie", + "Maart", + "April", + "Mei", + "Junie", + "Julie", + "Augustus", + "September", + "Oktober", + "November", + "Desember", + ] + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mrt", + "Apr", + "Mei", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Des", + ] + + day_names = [ + "", + "Maandag", + "Dinsdag", + "Woensdag", + "Donderdag", + "Vrydag", + "Saterdag", + "Sondag", + ] + day_abbreviations = ["", "Ma", "Di", "Wo", "Do", "Vr", "Za", "So"] + + +class BulgarianLocale(SlavicBaseLocale): + + names = ["bg", "bg_BG"] + + past = "{0} назад" + future = "напред {0}" + + timeframes = { + "now": "сега", + "second": "секунда", + "seconds": "{0} няколко секунди", + "minute": "минута", + "minutes": ["{0} минута", "{0} минути", "{0} минути"], + "hour": "час", + "hours": ["{0} час", "{0} часа", "{0} часа"], + "day": "ден", + "days": ["{0} ден", "{0} дни", "{0} дни"], + "month": "месец", + "months": ["{0} месец", "{0} месеца", "{0} месеца"], + "year": "година", + "years": ["{0} година", "{0} години", "{0} години"], + } + + month_names = [ + "", + "януари", + "февруари", + "март", + "април", + "май", + "юни", + "юли", + "август", + "септември", + "октомври", + "ноември", + "декември", + ] + month_abbreviations = [ + "", + "ян", + "февр", + "март", + "апр", + "май", + "юни", + "юли", + "авг", + "септ", + "окт", + "ноем", + "дек", + ] + + day_names = [ + "", + "понеделник", + "вторник", + "сряда", + "четвъртък", + "петък", + "събота", + "неделя", + ] + day_abbreviations = ["", "пон", "вт", "ср", "четв", "пет", "съб", "нед"] + + +class UkrainianLocale(SlavicBaseLocale): + + names = ["ua", "uk_ua"] + + past = "{0} тому" + future = "за {0}" + + timeframes = { + "now": "зараз", + "second": "секунда", + "seconds": "{0} кілька секунд", + "minute": "хвилину", + "minutes": ["{0} хвилину", "{0} хвилини", "{0} хвилин"], + "hour": "годину", + "hours": ["{0} годину", "{0} години", "{0} годин"], + "day": "день", + "days": ["{0} день", "{0} дні", "{0} днів"], + "month": "місяць", + "months": ["{0} місяць", "{0} місяці", "{0} місяців"], + "year": "рік", + "years": ["{0} рік", "{0} роки", "{0} років"], + } + + month_names = [ + "", + "січня", + "лютого", + "березня", + "квітня", + "травня", + "червня", + "липня", + "серпня", + "вересня", + "жовтня", + "листопада", + "грудня", + ] + month_abbreviations = [ + "", + "січ", + "лют", + "бер", + "квіт", + "трав", + "черв", + "лип", + "серп", + "вер", + "жовт", + "лист", + "груд", + ] + + day_names = [ + "", + "понеділок", + "вівторок", + "середа", + "четвер", + "п’ятниця", + "субота", + "неділя", + ] + day_abbreviations = ["", "пн", "вт", "ср", "чт", "пт", "сб", "нд"] + + +class MacedonianLocale(SlavicBaseLocale): + names = ["mk", "mk_mk"] + + past = "пред {0}" + future = "за {0}" + + timeframes = { + "now": "сега", + "second": "една секунда", + "seconds": ["{0} секунда", "{0} секунди", "{0} секунди"], + "minute": "една минута", + "minutes": ["{0} минута", "{0} минути", "{0} минути"], + "hour": "еден саат", + "hours": ["{0} саат", "{0} саати", "{0} саати"], + "day": "еден ден", + "days": ["{0} ден", "{0} дена", "{0} дена"], + "week": "една недела", + "weeks": ["{0} недела", "{0} недели", "{0} недели"], + "month": "еден месец", + "months": ["{0} месец", "{0} месеци", "{0} месеци"], + "year": "една година", + "years": ["{0} година", "{0} години", "{0} години"], + } + + meridians = {"am": "дп", "pm": "пп", "AM": "претпладне", "PM": "попладне"} + + month_names = [ + "", + "Јануари", + "Февруари", + "Март", + "Април", + "Мај", + "Јуни", + "Јули", + "Август", + "Септември", + "Октомври", + "Ноември", + "Декември", + ] + month_abbreviations = [ + "", + "Јан", + "Фев", + "Мар", + "Апр", + "Мај", + "Јун", + "Јул", + "Авг", + "Септ", + "Окт", + "Ноем", + "Декем", + ] + + day_names = [ + "", + "Понеделник", + "Вторник", + "Среда", + "Четврток", + "Петок", + "Сабота", + "Недела", + ] + day_abbreviations = [ + "", + "Пон", + "Вт", + "Сре", + "Чет", + "Пет", + "Саб", + "Нед", + ] + + +class GermanBaseLocale(Locale): + + past = "vor {0}" + future = "in {0}" + and_word = "und" + + timeframes = { + "now": "gerade eben", + "second": "eine Sekunde", + "seconds": "{0} Sekunden", + "minute": "einer Minute", + "minutes": "{0} Minuten", + "hour": "einer Stunde", + "hours": "{0} Stunden", + "day": "einem Tag", + "days": "{0} Tagen", + "week": "einer Woche", + "weeks": "{0} Wochen", + "month": "einem Monat", + "months": "{0} Monaten", + "year": "einem Jahr", + "years": "{0} Jahren", + } + + timeframes_only_distance = timeframes.copy() + timeframes_only_distance["minute"] = "eine Minute" + timeframes_only_distance["hour"] = "eine Stunde" + timeframes_only_distance["day"] = "ein Tag" + timeframes_only_distance["week"] = "eine Woche" + timeframes_only_distance["month"] = "ein Monat" + timeframes_only_distance["year"] = "ein Jahr" + + month_names = [ + "", + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", + ] + + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mär", + "Apr", + "Mai", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dez", + ] + + day_names = [ + "", + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag", + "Sonntag", + ] + + day_abbreviations = ["", "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + + def _ordinal_number(self, n): + return "{}.".format(n) + + def describe(self, timeframe, delta=0, only_distance=False): + """ Describes a delta within a timeframe in plain language. + + :param timeframe: a string representing a timeframe. + :param delta: a quantity representing a delta in a timeframe. + :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords + """ + + if not only_distance: + return super(GermanBaseLocale, self).describe( + timeframe, delta, only_distance + ) + + # German uses a different case without 'in' or 'ago' + humanized = self.timeframes_only_distance[timeframe].format(trunc(abs(delta))) + + return humanized + + +class GermanLocale(GermanBaseLocale, Locale): + + names = ["de", "de_de"] + + +class SwissLocale(GermanBaseLocale, Locale): + + names = ["de_ch"] + + +class AustrianLocale(GermanBaseLocale, Locale): + + names = ["de_at"] + + month_names = [ + "", + "Jänner", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", + ] + + +class NorwegianLocale(Locale): + + names = ["nb", "nb_no"] + + past = "for {0} siden" + future = "om {0}" + + timeframes = { + "now": "nå nettopp", + "second": "et sekund", + "seconds": "{0} noen sekunder", + "minute": "ett minutt", + "minutes": "{0} minutter", + "hour": "en time", + "hours": "{0} timer", + "day": "en dag", + "days": "{0} dager", + "month": "en måned", + "months": "{0} måneder", + "year": "ett år", + "years": "{0} år", + } + + month_names = [ + "", + "januar", + "februar", + "mars", + "april", + "mai", + "juni", + "juli", + "august", + "september", + "oktober", + "november", + "desember", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "mai", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "des", + ] + + day_names = [ + "", + "mandag", + "tirsdag", + "onsdag", + "torsdag", + "fredag", + "lørdag", + "søndag", + ] + day_abbreviations = ["", "ma", "ti", "on", "to", "fr", "lø", "sø"] + + +class NewNorwegianLocale(Locale): + + names = ["nn", "nn_no"] + + past = "for {0} sidan" + future = "om {0}" + + timeframes = { + "now": "no nettopp", + "second": "et sekund", + "seconds": "{0} nokre sekund", + "minute": "ett minutt", + "minutes": "{0} minutt", + "hour": "ein time", + "hours": "{0} timar", + "day": "ein dag", + "days": "{0} dagar", + "month": "en månad", + "months": "{0} månader", + "year": "eit år", + "years": "{0} år", + } + + month_names = [ + "", + "januar", + "februar", + "mars", + "april", + "mai", + "juni", + "juli", + "august", + "september", + "oktober", + "november", + "desember", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "mai", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "des", + ] + + day_names = [ + "", + "måndag", + "tysdag", + "onsdag", + "torsdag", + "fredag", + "laurdag", + "sundag", + ] + day_abbreviations = ["", "må", "ty", "on", "to", "fr", "la", "su"] + + +class PortugueseLocale(Locale): + names = ["pt", "pt_pt"] + + past = "há {0}" + future = "em {0}" + and_word = "e" + + timeframes = { + "now": "agora", + "second": "um segundo", + "seconds": "{0} segundos", + "minute": "um minuto", + "minutes": "{0} minutos", + "hour": "uma hora", + "hours": "{0} horas", + "day": "um dia", + "days": "{0} dias", + "week": "uma semana", + "weeks": "{0} semanas", + "month": "um mês", + "months": "{0} meses", + "year": "um ano", + "years": "{0} anos", + } + + month_names = [ + "", + "Janeiro", + "Fevereiro", + "Março", + "Abril", + "Maio", + "Junho", + "Julho", + "Agosto", + "Setembro", + "Outubro", + "Novembro", + "Dezembro", + ] + month_abbreviations = [ + "", + "Jan", + "Fev", + "Mar", + "Abr", + "Mai", + "Jun", + "Jul", + "Ago", + "Set", + "Out", + "Nov", + "Dez", + ] + + day_names = [ + "", + "Segunda-feira", + "Terça-feira", + "Quarta-feira", + "Quinta-feira", + "Sexta-feira", + "Sábado", + "Domingo", + ] + day_abbreviations = ["", "Seg", "Ter", "Qua", "Qui", "Sex", "Sab", "Dom"] + + +class BrazilianPortugueseLocale(PortugueseLocale): + names = ["pt_br"] + + past = "faz {0}" + + +class TagalogLocale(Locale): + + names = ["tl", "tl_ph"] + + past = "nakaraang {0}" + future = "{0} mula ngayon" + + timeframes = { + "now": "ngayon lang", + "second": "isang segundo", + "seconds": "{0} segundo", + "minute": "isang minuto", + "minutes": "{0} minuto", + "hour": "isang oras", + "hours": "{0} oras", + "day": "isang araw", + "days": "{0} araw", + "month": "isang buwan", + "months": "{0} buwan", + "year": "isang taon", + "years": "{0} taon", + } + + month_names = [ + "", + "Enero", + "Pebrero", + "Marso", + "Abril", + "Mayo", + "Hunyo", + "Hulyo", + "Agosto", + "Setyembre", + "Oktubre", + "Nobyembre", + "Disyembre", + ] + month_abbreviations = [ + "", + "Ene", + "Peb", + "Mar", + "Abr", + "May", + "Hun", + "Hul", + "Ago", + "Set", + "Okt", + "Nob", + "Dis", + ] + + day_names = [ + "", + "Lunes", + "Martes", + "Miyerkules", + "Huwebes", + "Biyernes", + "Sabado", + "Linggo", + ] + day_abbreviations = ["", "Lun", "Mar", "Miy", "Huw", "Biy", "Sab", "Lin"] + + def _ordinal_number(self, n): + return "ika-{}".format(n) + + +class VietnameseLocale(Locale): + + names = ["vi", "vi_vn"] + + past = "{0} trước" + future = "{0} nữa" + + timeframes = { + "now": "hiện tại", + "second": "một giây", + "seconds": "{0} giây", + "minute": "một phút", + "minutes": "{0} phút", + "hour": "một giờ", + "hours": "{0} giờ", + "day": "một ngày", + "days": "{0} ngày", + "week": "một tuần", + "weeks": "{0} tuần", + "month": "một tháng", + "months": "{0} tháng", + "year": "một năm", + "years": "{0} năm", + } + + month_names = [ + "", + "Tháng Một", + "Tháng Hai", + "Tháng Ba", + "Tháng Tư", + "Tháng Năm", + "Tháng Sáu", + "Tháng Bảy", + "Tháng Tám", + "Tháng Chín", + "Tháng Mười", + "Tháng Mười Một", + "Tháng Mười Hai", + ] + month_abbreviations = [ + "", + "Tháng 1", + "Tháng 2", + "Tháng 3", + "Tháng 4", + "Tháng 5", + "Tháng 6", + "Tháng 7", + "Tháng 8", + "Tháng 9", + "Tháng 10", + "Tháng 11", + "Tháng 12", + ] + + day_names = [ + "", + "Thứ Hai", + "Thứ Ba", + "Thứ Tư", + "Thứ Năm", + "Thứ Sáu", + "Thứ Bảy", + "Chủ Nhật", + ] + day_abbreviations = ["", "Thứ 2", "Thứ 3", "Thứ 4", "Thứ 5", "Thứ 6", "Thứ 7", "CN"] + + +class TurkishLocale(Locale): + + names = ["tr", "tr_tr"] + + past = "{0} önce" + future = "{0} sonra" + + timeframes = { + "now": "şimdi", + "second": "bir saniye", + "seconds": "{0} saniye", + "minute": "bir dakika", + "minutes": "{0} dakika", + "hour": "bir saat", + "hours": "{0} saat", + "day": "bir gün", + "days": "{0} gün", + "month": "bir ay", + "months": "{0} ay", + "year": "yıl", + "years": "{0} yıl", + } + + month_names = [ + "", + "Ocak", + "Şubat", + "Mart", + "Nisan", + "Mayıs", + "Haziran", + "Temmuz", + "Ağustos", + "Eylül", + "Ekim", + "Kasım", + "Aralık", + ] + month_abbreviations = [ + "", + "Oca", + "Şub", + "Mar", + "Nis", + "May", + "Haz", + "Tem", + "Ağu", + "Eyl", + "Eki", + "Kas", + "Ara", + ] + + day_names = [ + "", + "Pazartesi", + "Salı", + "Çarşamba", + "Perşembe", + "Cuma", + "Cumartesi", + "Pazar", + ] + day_abbreviations = ["", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"] + + +class AzerbaijaniLocale(Locale): + + names = ["az", "az_az"] + + past = "{0} əvvəl" + future = "{0} sonra" + + timeframes = { + "now": "indi", + "second": "saniyə", + "seconds": "{0} saniyə", + "minute": "bir dəqiqə", + "minutes": "{0} dəqiqə", + "hour": "bir saat", + "hours": "{0} saat", + "day": "bir gün", + "days": "{0} gün", + "month": "bir ay", + "months": "{0} ay", + "year": "il", + "years": "{0} il", + } + + month_names = [ + "", + "Yanvar", + "Fevral", + "Mart", + "Aprel", + "May", + "İyun", + "İyul", + "Avqust", + "Sentyabr", + "Oktyabr", + "Noyabr", + "Dekabr", + ] + month_abbreviations = [ + "", + "Yan", + "Fev", + "Mar", + "Apr", + "May", + "İyn", + "İyl", + "Avq", + "Sen", + "Okt", + "Noy", + "Dek", + ] + + day_names = [ + "", + "Bazar ertəsi", + "Çərşənbə axşamı", + "Çərşənbə", + "Cümə axşamı", + "Cümə", + "Şənbə", + "Bazar", + ] + day_abbreviations = ["", "Ber", "Çax", "Çər", "Cax", "Cüm", "Şnb", "Bzr"] + + +class ArabicLocale(Locale): + names = [ + "ar", + "ar_ae", + "ar_bh", + "ar_dj", + "ar_eg", + "ar_eh", + "ar_er", + "ar_km", + "ar_kw", + "ar_ly", + "ar_om", + "ar_qa", + "ar_sa", + "ar_sd", + "ar_so", + "ar_ss", + "ar_td", + "ar_ye", + ] + + past = "منذ {0}" + future = "خلال {0}" + + timeframes = { + "now": "الآن", + "second": "ثانية", + "seconds": {"double": "ثانيتين", "ten": "{0} ثوان", "higher": "{0} ثانية"}, + "minute": "دقيقة", + "minutes": {"double": "دقيقتين", "ten": "{0} دقائق", "higher": "{0} دقيقة"}, + "hour": "ساعة", + "hours": {"double": "ساعتين", "ten": "{0} ساعات", "higher": "{0} ساعة"}, + "day": "يوم", + "days": {"double": "يومين", "ten": "{0} أيام", "higher": "{0} يوم"}, + "month": "شهر", + "months": {"double": "شهرين", "ten": "{0} أشهر", "higher": "{0} شهر"}, + "year": "سنة", + "years": {"double": "سنتين", "ten": "{0} سنوات", "higher": "{0} سنة"}, + } + + month_names = [ + "", + "يناير", + "فبراير", + "مارس", + "أبريل", + "مايو", + "يونيو", + "يوليو", + "أغسطس", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر", + ] + month_abbreviations = [ + "", + "يناير", + "فبراير", + "مارس", + "أبريل", + "مايو", + "يونيو", + "يوليو", + "أغسطس", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر", + ] + + day_names = [ + "", + "الإثنين", + "الثلاثاء", + "الأربعاء", + "الخميس", + "الجمعة", + "السبت", + "الأحد", + ] + day_abbreviations = ["", "إثنين", "ثلاثاء", "أربعاء", "خميس", "جمعة", "سبت", "أحد"] + + def _format_timeframe(self, timeframe, delta): + form = self.timeframes[timeframe] + delta = abs(delta) + if isinstance(form, dict): + if delta == 2: + form = form["double"] + elif delta > 2 and delta <= 10: + form = form["ten"] + else: + form = form["higher"] + + return form.format(delta) + + +class LevantArabicLocale(ArabicLocale): + names = ["ar_iq", "ar_jo", "ar_lb", "ar_ps", "ar_sy"] + month_names = [ + "", + "كانون الثاني", + "شباط", + "آذار", + "نيسان", + "أيار", + "حزيران", + "تموز", + "آب", + "أيلول", + "تشرين الأول", + "تشرين الثاني", + "كانون الأول", + ] + month_abbreviations = [ + "", + "كانون الثاني", + "شباط", + "آذار", + "نيسان", + "أيار", + "حزيران", + "تموز", + "آب", + "أيلول", + "تشرين الأول", + "تشرين الثاني", + "كانون الأول", + ] + + +class AlgeriaTunisiaArabicLocale(ArabicLocale): + names = ["ar_tn", "ar_dz"] + month_names = [ + "", + "جانفي", + "فيفري", + "مارس", + "أفريل", + "ماي", + "جوان", + "جويلية", + "أوت", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر", + ] + month_abbreviations = [ + "", + "جانفي", + "فيفري", + "مارس", + "أفريل", + "ماي", + "جوان", + "جويلية", + "أوت", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر", + ] + + +class MauritaniaArabicLocale(ArabicLocale): + names = ["ar_mr"] + month_names = [ + "", + "يناير", + "فبراير", + "مارس", + "إبريل", + "مايو", + "يونيو", + "يوليو", + "أغشت", + "شتمبر", + "أكتوبر", + "نوفمبر", + "دجمبر", + ] + month_abbreviations = [ + "", + "يناير", + "فبراير", + "مارس", + "إبريل", + "مايو", + "يونيو", + "يوليو", + "أغشت", + "شتمبر", + "أكتوبر", + "نوفمبر", + "دجمبر", + ] + + +class MoroccoArabicLocale(ArabicLocale): + names = ["ar_ma"] + month_names = [ + "", + "يناير", + "فبراير", + "مارس", + "أبريل", + "ماي", + "يونيو", + "يوليوز", + "غشت", + "شتنبر", + "أكتوبر", + "نونبر", + "دجنبر", + ] + month_abbreviations = [ + "", + "يناير", + "فبراير", + "مارس", + "أبريل", + "ماي", + "يونيو", + "يوليوز", + "غشت", + "شتنبر", + "أكتوبر", + "نونبر", + "دجنبر", + ] + + +class IcelandicLocale(Locale): + def _format_timeframe(self, timeframe, delta): + + timeframe = self.timeframes[timeframe] + if delta < 0: + timeframe = timeframe[0] + elif delta > 0: + timeframe = timeframe[1] + + return timeframe.format(abs(delta)) + + names = ["is", "is_is"] + + past = "fyrir {0} síðan" + future = "eftir {0}" + + timeframes = { + "now": "rétt í þessu", + "second": ("sekúndu", "sekúndu"), + "seconds": ("{0} nokkrum sekúndum", "nokkrar sekúndur"), + "minute": ("einni mínútu", "eina mínútu"), + "minutes": ("{0} mínútum", "{0} mínútur"), + "hour": ("einum tíma", "einn tíma"), + "hours": ("{0} tímum", "{0} tíma"), + "day": ("einum degi", "einn dag"), + "days": ("{0} dögum", "{0} daga"), + "month": ("einum mánuði", "einn mánuð"), + "months": ("{0} mánuðum", "{0} mánuði"), + "year": ("einu ári", "eitt ár"), + "years": ("{0} árum", "{0} ár"), + } + + meridians = {"am": "f.h.", "pm": "e.h.", "AM": "f.h.", "PM": "e.h."} + + month_names = [ + "", + "janúar", + "febrúar", + "mars", + "apríl", + "maí", + "júní", + "júlí", + "ágúst", + "september", + "október", + "nóvember", + "desember", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "maí", + "jún", + "júl", + "ágú", + "sep", + "okt", + "nóv", + "des", + ] + + day_names = [ + "", + "mánudagur", + "þriðjudagur", + "miðvikudagur", + "fimmtudagur", + "föstudagur", + "laugardagur", + "sunnudagur", + ] + day_abbreviations = ["", "mán", "þri", "mið", "fim", "fös", "lau", "sun"] + + +class DanishLocale(Locale): + + names = ["da", "da_dk"] + + past = "for {0} siden" + future = "efter {0}" + and_word = "og" + + timeframes = { + "now": "lige nu", + "second": "et sekund", + "seconds": "{0} et par sekunder", + "minute": "et minut", + "minutes": "{0} minutter", + "hour": "en time", + "hours": "{0} timer", + "day": "en dag", + "days": "{0} dage", + "month": "en måned", + "months": "{0} måneder", + "year": "et år", + "years": "{0} år", + } + + month_names = [ + "", + "januar", + "februar", + "marts", + "april", + "maj", + "juni", + "juli", + "august", + "september", + "oktober", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "maj", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "mandag", + "tirsdag", + "onsdag", + "torsdag", + "fredag", + "lørdag", + "søndag", + ] + day_abbreviations = ["", "man", "tir", "ons", "tor", "fre", "lør", "søn"] + + +class MalayalamLocale(Locale): + + names = ["ml"] + + past = "{0} മുമ്പ്" + future = "{0} ശേഷം" + + timeframes = { + "now": "ഇപ്പോൾ", + "second": "ഒരു നിമിഷം", + "seconds": "{0} സെക്കന്റ്‌", + "minute": "ഒരു മിനിറ്റ്", + "minutes": "{0} മിനിറ്റ്", + "hour": "ഒരു മണിക്കൂർ", + "hours": "{0} മണിക്കൂർ", + "day": "ഒരു ദിവസം ", + "days": "{0} ദിവസം ", + "month": "ഒരു മാസം ", + "months": "{0} മാസം ", + "year": "ഒരു വർഷം ", + "years": "{0} വർഷം ", + } + + meridians = { + "am": "രാവിലെ", + "pm": "ഉച്ചക്ക് ശേഷം", + "AM": "രാവിലെ", + "PM": "ഉച്ചക്ക് ശേഷം", + } + + month_names = [ + "", + "ജനുവരി", + "ഫെബ്രുവരി", + "മാർച്ച്‌", + "ഏപ്രിൽ ", + "മെയ്‌ ", + "ജൂണ്‍", + "ജൂലൈ", + "ഓഗസ്റ്റ്‌", + "സെപ്റ്റംബർ", + "ഒക്ടോബർ", + "നവംബർ", + "ഡിസംബർ", + ] + month_abbreviations = [ + "", + "ജനു", + "ഫെബ് ", + "മാർ", + "ഏപ്രിൽ", + "മേയ്", + "ജൂണ്‍", + "ജൂലൈ", + "ഓഗസ്റ", + "സെപ്റ്റ", + "ഒക്ടോ", + "നവം", + "ഡിസം", + ] + + day_names = ["", "തിങ്കള്‍", "ചൊവ്വ", "ബുധന്‍", "വ്യാഴം", "വെള്ളി", "ശനി", "ഞായര്‍"] + day_abbreviations = [ + "", + "തിങ്കള്‍", + "ചൊവ്വ", + "ബുധന്‍", + "വ്യാഴം", + "വെള്ളി", + "ശനി", + "ഞായര്‍", + ] + + +class HindiLocale(Locale): + + names = ["hi"] + + past = "{0} पहले" + future = "{0} बाद" + + timeframes = { + "now": "अभी", + "second": "एक पल", + "seconds": "{0} सेकंड्", + "minute": "एक मिनट ", + "minutes": "{0} मिनट ", + "hour": "एक घंटा", + "hours": "{0} घंटे", + "day": "एक दिन", + "days": "{0} दिन", + "month": "एक माह ", + "months": "{0} महीने ", + "year": "एक वर्ष ", + "years": "{0} साल ", + } + + meridians = {"am": "सुबह", "pm": "शाम", "AM": "सुबह", "PM": "शाम"} + + month_names = [ + "", + "जनवरी", + "फरवरी", + "मार्च", + "अप्रैल ", + "मई", + "जून", + "जुलाई", + "अगस्त", + "सितंबर", + "अक्टूबर", + "नवंबर", + "दिसंबर", + ] + month_abbreviations = [ + "", + "जन", + "फ़र", + "मार्च", + "अप्रै", + "मई", + "जून", + "जुलाई", + "आग", + "सित", + "अकत", + "नवे", + "दिस", + ] + + day_names = [ + "", + "सोमवार", + "मंगलवार", + "बुधवार", + "गुरुवार", + "शुक्रवार", + "शनिवार", + "रविवार", + ] + day_abbreviations = ["", "सोम", "मंगल", "बुध", "गुरुवार", "शुक्र", "शनि", "रवि"] + + +class CzechLocale(Locale): + names = ["cs", "cs_cz"] + + timeframes = { + "now": "Teď", + "second": {"past": "vteřina", "future": "vteřina", "zero": "vteřina"}, + "seconds": {"past": "{0} sekundami", "future": ["{0} sekundy", "{0} sekund"]}, + "minute": {"past": "minutou", "future": "minutu", "zero": "{0} minut"}, + "minutes": {"past": "{0} minutami", "future": ["{0} minuty", "{0} minut"]}, + "hour": {"past": "hodinou", "future": "hodinu", "zero": "{0} hodin"}, + "hours": {"past": "{0} hodinami", "future": ["{0} hodiny", "{0} hodin"]}, + "day": {"past": "dnem", "future": "den", "zero": "{0} dnů"}, + "days": {"past": "{0} dny", "future": ["{0} dny", "{0} dnů"]}, + "month": {"past": "měsícem", "future": "měsíc", "zero": "{0} měsíců"}, + "months": {"past": "{0} měsíci", "future": ["{0} měsíce", "{0} měsíců"]}, + "year": {"past": "rokem", "future": "rok", "zero": "{0} let"}, + "years": {"past": "{0} lety", "future": ["{0} roky", "{0} let"]}, + } + + past = "Před {0}" + future = "Za {0}" + + month_names = [ + "", + "leden", + "únor", + "březen", + "duben", + "květen", + "červen", + "červenec", + "srpen", + "září", + "říjen", + "listopad", + "prosinec", + ] + month_abbreviations = [ + "", + "led", + "úno", + "bře", + "dub", + "kvě", + "čvn", + "čvc", + "srp", + "zář", + "říj", + "lis", + "pro", + ] + + day_names = [ + "", + "pondělí", + "úterý", + "středa", + "čtvrtek", + "pátek", + "sobota", + "neděle", + ] + day_abbreviations = ["", "po", "út", "st", "čt", "pá", "so", "ne"] + + def _format_timeframe(self, timeframe, delta): + """Czech aware time frame format function, takes into account + the differences between past and future forms.""" + form = self.timeframes[timeframe] + if isinstance(form, dict): + if delta == 0: + form = form["zero"] # And *never* use 0 in the singular! + elif delta > 0: + form = form["future"] + else: + form = form["past"] + delta = abs(delta) + + if isinstance(form, list): + if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): + form = form[0] + else: + form = form[1] + + return form.format(delta) + + +class SlovakLocale(Locale): + names = ["sk", "sk_sk"] + + timeframes = { + "now": "Teraz", + "second": {"past": "sekundou", "future": "sekundu", "zero": "{0} sekúnd"}, + "seconds": {"past": "{0} sekundami", "future": ["{0} sekundy", "{0} sekúnd"]}, + "minute": {"past": "minútou", "future": "minútu", "zero": "{0} minút"}, + "minutes": {"past": "{0} minútami", "future": ["{0} minúty", "{0} minút"]}, + "hour": {"past": "hodinou", "future": "hodinu", "zero": "{0} hodín"}, + "hours": {"past": "{0} hodinami", "future": ["{0} hodiny", "{0} hodín"]}, + "day": {"past": "dňom", "future": "deň", "zero": "{0} dní"}, + "days": {"past": "{0} dňami", "future": ["{0} dni", "{0} dní"]}, + "week": {"past": "týždňom", "future": "týždeň", "zero": "{0} týždňov"}, + "weeks": {"past": "{0} týždňami", "future": ["{0} týždne", "{0} týždňov"]}, + "month": {"past": "mesiacom", "future": "mesiac", "zero": "{0} mesiacov"}, + "months": {"past": "{0} mesiacmi", "future": ["{0} mesiace", "{0} mesiacov"]}, + "year": {"past": "rokom", "future": "rok", "zero": "{0} rokov"}, + "years": {"past": "{0} rokmi", "future": ["{0} roky", "{0} rokov"]}, + } + + past = "Pred {0}" + future = "O {0}" + and_word = "a" + + month_names = [ + "", + "január", + "február", + "marec", + "apríl", + "máj", + "jún", + "júl", + "august", + "september", + "október", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "máj", + "jún", + "júl", + "aug", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "pondelok", + "utorok", + "streda", + "štvrtok", + "piatok", + "sobota", + "nedeľa", + ] + day_abbreviations = ["", "po", "ut", "st", "št", "pi", "so", "ne"] + + def _format_timeframe(self, timeframe, delta): + """Slovak aware time frame format function, takes into account + the differences between past and future forms.""" + form = self.timeframes[timeframe] + if isinstance(form, dict): + if delta == 0: + form = form["zero"] # And *never* use 0 in the singular! + elif delta > 0: + form = form["future"] + else: + form = form["past"] + delta = abs(delta) + + if isinstance(form, list): + if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): + form = form[0] + else: + form = form[1] + + return form.format(delta) + + +class FarsiLocale(Locale): + + names = ["fa", "fa_ir"] + + past = "{0} قبل" + future = "در {0}" + + timeframes = { + "now": "اکنون", + "second": "یک لحظه", + "seconds": "{0} ثانیه", + "minute": "یک دقیقه", + "minutes": "{0} دقیقه", + "hour": "یک ساعت", + "hours": "{0} ساعت", + "day": "یک روز", + "days": "{0} روز", + "month": "یک ماه", + "months": "{0} ماه", + "year": "یک سال", + "years": "{0} سال", + } + + meridians = { + "am": "قبل از ظهر", + "pm": "بعد از ظهر", + "AM": "قبل از ظهر", + "PM": "بعد از ظهر", + } + + month_names = [ + "", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ] + + day_names = [ + "", + "دو شنبه", + "سه شنبه", + "چهارشنبه", + "پنجشنبه", + "جمعه", + "شنبه", + "یکشنبه", + ] + day_abbreviations = ["", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + +class HebrewLocale(Locale): + + names = ["he", "he_IL"] + + past = "לפני {0}" + future = "בעוד {0}" + and_word = "ו" + + timeframes = { + "now": "הרגע", + "second": "שנייה", + "seconds": "{0} שניות", + "minute": "דקה", + "minutes": "{0} דקות", + "hour": "שעה", + "hours": "{0} שעות", + "2-hours": "שעתיים", + "day": "יום", + "days": "{0} ימים", + "2-days": "יומיים", + "week": "שבוע", + "weeks": "{0} שבועות", + "2-weeks": "שבועיים", + "month": "חודש", + "months": "{0} חודשים", + "2-months": "חודשיים", + "year": "שנה", + "years": "{0} שנים", + "2-years": "שנתיים", + } + + meridians = { + "am": 'לפנ"צ', + "pm": 'אחר"צ', + "AM": "לפני הצהריים", + "PM": "אחרי הצהריים", + } + + month_names = [ + "", + "ינואר", + "פברואר", + "מרץ", + "אפריל", + "מאי", + "יוני", + "יולי", + "אוגוסט", + "ספטמבר", + "אוקטובר", + "נובמבר", + "דצמבר", + ] + month_abbreviations = [ + "", + "ינו׳", + "פבר׳", + "מרץ", + "אפר׳", + "מאי", + "יוני", + "יולי", + "אוג׳", + "ספט׳", + "אוק׳", + "נוב׳", + "דצמ׳", + ] + + day_names = ["", "שני", "שלישי", "רביעי", "חמישי", "שישי", "שבת", "ראשון"] + day_abbreviations = ["", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳", "א׳"] + + def _format_timeframe(self, timeframe, delta): + """Hebrew couple of aware""" + couple = "2-{}".format(timeframe) + single = timeframe.rstrip("s") + if abs(delta) == 2 and couple in self.timeframes: + key = couple + elif abs(delta) == 1 and single in self.timeframes: + key = single + else: + key = timeframe + + return self.timeframes[key].format(trunc(abs(delta))) + + def describe_multi(self, timeframes, only_distance=False): + """ Describes a delta within multiple timeframes in plain language. + In Hebrew, the and word behaves a bit differently. + + :param timeframes: a list of string, quantity pairs each representing a timeframe and delta. + :param only_distance: return only distance eg: "2 hours and 11 seconds" without "in" or "ago" keywords + """ + + humanized = "" + for index, (timeframe, delta) in enumerate(timeframes): + last_humanized = self._format_timeframe(timeframe, delta) + if index == 0: + humanized = last_humanized + elif index == len(timeframes) - 1: # Must have at least 2 items + humanized += " " + self.and_word + if last_humanized[0].isdecimal(): + humanized += "־" + humanized += last_humanized + else: # Don't add for the last one + humanized += ", " + last_humanized + + if not only_distance: + humanized = self._format_relative(humanized, timeframe, delta) + + return humanized + + +class MarathiLocale(Locale): + + names = ["mr"] + + past = "{0} आधी" + future = "{0} नंतर" + + timeframes = { + "now": "सद्य", + "second": "एक सेकंद", + "seconds": "{0} सेकंद", + "minute": "एक मिनिट ", + "minutes": "{0} मिनिट ", + "hour": "एक तास", + "hours": "{0} तास", + "day": "एक दिवस", + "days": "{0} दिवस", + "month": "एक महिना ", + "months": "{0} महिने ", + "year": "एक वर्ष ", + "years": "{0} वर्ष ", + } + + meridians = {"am": "सकाळ", "pm": "संध्याकाळ", "AM": "सकाळ", "PM": "संध्याकाळ"} + + month_names = [ + "", + "जानेवारी", + "फेब्रुवारी", + "मार्च", + "एप्रिल", + "मे", + "जून", + "जुलै", + "अॉगस्ट", + "सप्टेंबर", + "अॉक्टोबर", + "नोव्हेंबर", + "डिसेंबर", + ] + month_abbreviations = [ + "", + "जान", + "फेब्रु", + "मार्च", + "एप्रि", + "मे", + "जून", + "जुलै", + "अॉग", + "सप्टें", + "अॉक्टो", + "नोव्हें", + "डिसें", + ] + + day_names = [ + "", + "सोमवार", + "मंगळवार", + "बुधवार", + "गुरुवार", + "शुक्रवार", + "शनिवार", + "रविवार", + ] + day_abbreviations = ["", "सोम", "मंगळ", "बुध", "गुरु", "शुक्र", "शनि", "रवि"] + + +def _map_locales(): + + locales = {} + + for _, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): + if issubclass(cls, Locale): # pragma: no branch + for name in cls.names: + locales[name.lower()] = cls + + return locales + + +class CatalanLocale(Locale): + names = ["ca", "ca_es", "ca_ad", "ca_fr", "ca_it"] + past = "Fa {0}" + future = "En {0}" + and_word = "i" + + timeframes = { + "now": "Ara mateix", + "second": "un segon", + "seconds": "{0} segons", + "minute": "1 minut", + "minutes": "{0} minuts", + "hour": "una hora", + "hours": "{0} hores", + "day": "un dia", + "days": "{0} dies", + "month": "un mes", + "months": "{0} mesos", + "year": "un any", + "years": "{0} anys", + } + + month_names = [ + "", + "Gener", + "Febrer", + "Març", + "Abril", + "Maig", + "Juny", + "Juliol", + "Agost", + "Setembre", + "Octubre", + "Novembre", + "Desembre", + ] + month_abbreviations = [ + "", + "Gener", + "Febrer", + "Març", + "Abril", + "Maig", + "Juny", + "Juliol", + "Agost", + "Setembre", + "Octubre", + "Novembre", + "Desembre", + ] + day_names = [ + "", + "Dilluns", + "Dimarts", + "Dimecres", + "Dijous", + "Divendres", + "Dissabte", + "Diumenge", + ] + day_abbreviations = [ + "", + "Dilluns", + "Dimarts", + "Dimecres", + "Dijous", + "Divendres", + "Dissabte", + "Diumenge", + ] + + +class BasqueLocale(Locale): + names = ["eu", "eu_eu"] + past = "duela {0}" + future = "{0}" # I don't know what's the right phrase in Basque for the future. + + timeframes = { + "now": "Orain", + "second": "segundo bat", + "seconds": "{0} segundu", + "minute": "minutu bat", + "minutes": "{0} minutu", + "hour": "ordu bat", + "hours": "{0} ordu", + "day": "egun bat", + "days": "{0} egun", + "month": "hilabete bat", + "months": "{0} hilabet", + "year": "urte bat", + "years": "{0} urte", + } + + month_names = [ + "", + "urtarrilak", + "otsailak", + "martxoak", + "apirilak", + "maiatzak", + "ekainak", + "uztailak", + "abuztuak", + "irailak", + "urriak", + "azaroak", + "abenduak", + ] + month_abbreviations = [ + "", + "urt", + "ots", + "mar", + "api", + "mai", + "eka", + "uzt", + "abu", + "ira", + "urr", + "aza", + "abe", + ] + day_names = [ + "", + "astelehena", + "asteartea", + "asteazkena", + "osteguna", + "ostirala", + "larunbata", + "igandea", + ] + day_abbreviations = ["", "al", "ar", "az", "og", "ol", "lr", "ig"] + + +class HungarianLocale(Locale): + + names = ["hu", "hu_hu"] + + past = "{0} ezelőtt" + future = "{0} múlva" + + timeframes = { + "now": "éppen most", + "second": {"past": "egy második", "future": "egy második"}, + "seconds": {"past": "{0} másodpercekkel", "future": "{0} pár másodperc"}, + "minute": {"past": "egy perccel", "future": "egy perc"}, + "minutes": {"past": "{0} perccel", "future": "{0} perc"}, + "hour": {"past": "egy órával", "future": "egy óra"}, + "hours": {"past": "{0} órával", "future": "{0} óra"}, + "day": {"past": "egy nappal", "future": "egy nap"}, + "days": {"past": "{0} nappal", "future": "{0} nap"}, + "month": {"past": "egy hónappal", "future": "egy hónap"}, + "months": {"past": "{0} hónappal", "future": "{0} hónap"}, + "year": {"past": "egy évvel", "future": "egy év"}, + "years": {"past": "{0} évvel", "future": "{0} év"}, + } + + month_names = [ + "", + "január", + "február", + "március", + "április", + "május", + "június", + "július", + "augusztus", + "szeptember", + "október", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "febr", + "márc", + "ápr", + "máj", + "jún", + "júl", + "aug", + "szept", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "hétfő", + "kedd", + "szerda", + "csütörtök", + "péntek", + "szombat", + "vasárnap", + ] + day_abbreviations = ["", "hét", "kedd", "szer", "csüt", "pént", "szom", "vas"] + + meridians = {"am": "de", "pm": "du", "AM": "DE", "PM": "DU"} + + def _format_timeframe(self, timeframe, delta): + form = self.timeframes[timeframe] + + if isinstance(form, dict): + if delta > 0: + form = form["future"] + else: + form = form["past"] + + return form.format(abs(delta)) + + +class EsperantoLocale(Locale): + names = ["eo", "eo_xx"] + past = "antaŭ {0}" + future = "post {0}" + + timeframes = { + "now": "nun", + "second": "sekundo", + "seconds": "{0} kelkaj sekundoj", + "minute": "unu minuto", + "minutes": "{0} minutoj", + "hour": "un horo", + "hours": "{0} horoj", + "day": "unu tago", + "days": "{0} tagoj", + "month": "unu monato", + "months": "{0} monatoj", + "year": "unu jaro", + "years": "{0} jaroj", + } + + month_names = [ + "", + "januaro", + "februaro", + "marto", + "aprilo", + "majo", + "junio", + "julio", + "aŭgusto", + "septembro", + "oktobro", + "novembro", + "decembro", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "maj", + "jun", + "jul", + "aŭg", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "lundo", + "mardo", + "merkredo", + "ĵaŭdo", + "vendredo", + "sabato", + "dimanĉo", + ] + day_abbreviations = ["", "lun", "mar", "mer", "ĵaŭ", "ven", "sab", "dim"] + + meridians = {"am": "atm", "pm": "ptm", "AM": "ATM", "PM": "PTM"} + + ordinal_day_re = r"((?P[1-3]?[0-9](?=a))a)" + + def _ordinal_number(self, n): + return "{}a".format(n) + + +class ThaiLocale(Locale): + + names = ["th", "th_th"] + + past = "{0}{1}ที่ผ่านมา" + future = "ในอีก{1}{0}" + + timeframes = { + "now": "ขณะนี้", + "second": "วินาที", + "seconds": "{0} ไม่กี่วินาที", + "minute": "1 นาที", + "minutes": "{0} นาที", + "hour": "1 ชั่วโมง", + "hours": "{0} ชั่วโมง", + "day": "1 วัน", + "days": "{0} วัน", + "month": "1 เดือน", + "months": "{0} เดือน", + "year": "1 ปี", + "years": "{0} ปี", + } + + month_names = [ + "", + "มกราคม", + "กุมภาพันธ์", + "มีนาคม", + "เมษายน", + "พฤษภาคม", + "มิถุนายน", + "กรกฎาคม", + "สิงหาคม", + "กันยายน", + "ตุลาคม", + "พฤศจิกายน", + "ธันวาคม", + ] + month_abbreviations = [ + "", + "ม.ค.", + "ก.พ.", + "มี.ค.", + "เม.ย.", + "พ.ค.", + "มิ.ย.", + "ก.ค.", + "ส.ค.", + "ก.ย.", + "ต.ค.", + "พ.ย.", + "ธ.ค.", + ] + + day_names = ["", "จันทร์", "อังคาร", "พุธ", "พฤหัสบดี", "ศุกร์", "เสาร์", "อาทิตย์"] + day_abbreviations = ["", "จ", "อ", "พ", "พฤ", "ศ", "ส", "อา"] + + meridians = {"am": "am", "pm": "pm", "AM": "AM", "PM": "PM"} + + BE_OFFSET = 543 + + def year_full(self, year): + """Thai always use Buddhist Era (BE) which is CE + 543""" + year += self.BE_OFFSET + return "{:04d}".format(year) + + def year_abbreviation(self, year): + """Thai always use Buddhist Era (BE) which is CE + 543""" + year += self.BE_OFFSET + return "{:04d}".format(year)[2:] + + def _format_relative(self, humanized, timeframe, delta): + """Thai normally doesn't have any space between words""" + if timeframe == "now": + return humanized + space = "" if timeframe == "seconds" else " " + direction = self.past if delta < 0 else self.future + + return direction.format(humanized, space) + + +class BengaliLocale(Locale): + + names = ["bn", "bn_bd", "bn_in"] + + past = "{0} আগে" + future = "{0} পরে" + + timeframes = { + "now": "এখন", + "second": "একটি দ্বিতীয়", + "seconds": "{0} সেকেন্ড", + "minute": "এক মিনিট", + "minutes": "{0} মিনিট", + "hour": "এক ঘণ্টা", + "hours": "{0} ঘণ্টা", + "day": "এক দিন", + "days": "{0} দিন", + "month": "এক মাস", + "months": "{0} মাস ", + "year": "এক বছর", + "years": "{0} বছর", + } + + meridians = {"am": "সকাল", "pm": "বিকাল", "AM": "সকাল", "PM": "বিকাল"} + + month_names = [ + "", + "জানুয়ারি", + "ফেব্রুয়ারি", + "মার্চ", + "এপ্রিল", + "মে", + "জুন", + "জুলাই", + "আগস্ট", + "সেপ্টেম্বর", + "অক্টোবর", + "নভেম্বর", + "ডিসেম্বর", + ] + month_abbreviations = [ + "", + "জানু", + "ফেব", + "মার্চ", + "এপ্রি", + "মে", + "জুন", + "জুল", + "অগা", + "সেপ্ট", + "অক্টো", + "নভে", + "ডিসে", + ] + + day_names = [ + "", + "সোমবার", + "মঙ্গলবার", + "বুধবার", + "বৃহস্পতিবার", + "শুক্রবার", + "শনিবার", + "রবিবার", + ] + day_abbreviations = ["", "সোম", "মঙ্গল", "বুধ", "বৃহঃ", "শুক্র", "শনি", "রবি"] + + def _ordinal_number(self, n): + if n > 10 or n == 0: + return "{}তম".format(n) + if n in [1, 5, 7, 8, 9, 10]: + return "{}ম".format(n) + if n in [2, 3]: + return "{}য়".format(n) + if n == 4: + return "{}র্থ".format(n) + if n == 6: + return "{}ষ্ঠ".format(n) + + +class RomanshLocale(Locale): + + names = ["rm", "rm_ch"] + + past = "avant {0}" + future = "en {0}" + + timeframes = { + "now": "en quest mument", + "second": "in secunda", + "seconds": "{0} secundas", + "minute": "ina minuta", + "minutes": "{0} minutas", + "hour": "in'ura", + "hours": "{0} ura", + "day": "in di", + "days": "{0} dis", + "month": "in mais", + "months": "{0} mais", + "year": "in onn", + "years": "{0} onns", + } + + month_names = [ + "", + "schaner", + "favrer", + "mars", + "avrigl", + "matg", + "zercladur", + "fanadur", + "avust", + "settember", + "october", + "november", + "december", + ] + + month_abbreviations = [ + "", + "schan", + "fav", + "mars", + "avr", + "matg", + "zer", + "fan", + "avu", + "set", + "oct", + "nov", + "dec", + ] + + day_names = [ + "", + "glindesdi", + "mardi", + "mesemna", + "gievgia", + "venderdi", + "sonda", + "dumengia", + ] + + day_abbreviations = ["", "gli", "ma", "me", "gie", "ve", "so", "du"] + + +class RomanianLocale(Locale): + names = ["ro", "ro_ro"] + + past = "{0} în urmă" + future = "peste {0}" + and_word = "și" + + timeframes = { + "now": "acum", + "second": "o secunda", + "seconds": "{0} câteva secunde", + "minute": "un minut", + "minutes": "{0} minute", + "hour": "o oră", + "hours": "{0} ore", + "day": "o zi", + "days": "{0} zile", + "month": "o lună", + "months": "{0} luni", + "year": "un an", + "years": "{0} ani", + } + + month_names = [ + "", + "ianuarie", + "februarie", + "martie", + "aprilie", + "mai", + "iunie", + "iulie", + "august", + "septembrie", + "octombrie", + "noiembrie", + "decembrie", + ] + month_abbreviations = [ + "", + "ian", + "febr", + "mart", + "apr", + "mai", + "iun", + "iul", + "aug", + "sept", + "oct", + "nov", + "dec", + ] + + day_names = [ + "", + "luni", + "marți", + "miercuri", + "joi", + "vineri", + "sâmbătă", + "duminică", + ] + day_abbreviations = ["", "Lun", "Mar", "Mie", "Joi", "Vin", "Sâm", "Dum"] + + +class SlovenianLocale(Locale): + names = ["sl", "sl_si"] + + past = "pred {0}" + future = "čez {0}" + and_word = "in" + + timeframes = { + "now": "zdaj", + "second": "sekundo", + "seconds": "{0} sekund", + "minute": "minuta", + "minutes": "{0} minutami", + "hour": "uro", + "hours": "{0} ur", + "day": "dan", + "days": "{0} dni", + "month": "mesec", + "months": "{0} mesecev", + "year": "leto", + "years": "{0} let", + } + + meridians = {"am": "", "pm": "", "AM": "", "PM": ""} + + month_names = [ + "", + "Januar", + "Februar", + "Marec", + "April", + "Maj", + "Junij", + "Julij", + "Avgust", + "September", + "Oktober", + "November", + "December", + ] + + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "Maj", + "Jun", + "Jul", + "Avg", + "Sep", + "Okt", + "Nov", + "Dec", + ] + + day_names = [ + "", + "Ponedeljek", + "Torek", + "Sreda", + "Četrtek", + "Petek", + "Sobota", + "Nedelja", + ] + + day_abbreviations = ["", "Pon", "Tor", "Sre", "Čet", "Pet", "Sob", "Ned"] + + +class IndonesianLocale(Locale): + + names = ["id", "id_id"] + + past = "{0} yang lalu" + future = "dalam {0}" + and_word = "dan" + + timeframes = { + "now": "baru saja", + "second": "1 sebentar", + "seconds": "{0} detik", + "minute": "1 menit", + "minutes": "{0} menit", + "hour": "1 jam", + "hours": "{0} jam", + "day": "1 hari", + "days": "{0} hari", + "month": "1 bulan", + "months": "{0} bulan", + "year": "1 tahun", + "years": "{0} tahun", + } + + meridians = {"am": "", "pm": "", "AM": "", "PM": ""} + + month_names = [ + "", + "Januari", + "Februari", + "Maret", + "April", + "Mei", + "Juni", + "Juli", + "Agustus", + "September", + "Oktober", + "November", + "Desember", + ] + + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "Mei", + "Jun", + "Jul", + "Ags", + "Sept", + "Okt", + "Nov", + "Des", + ] + + day_names = ["", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"] + + day_abbreviations = [ + "", + "Senin", + "Selasa", + "Rabu", + "Kamis", + "Jumat", + "Sabtu", + "Minggu", + ] + + +class NepaliLocale(Locale): + names = ["ne", "ne_np"] + + past = "{0} पहिले" + future = "{0} पछी" + + timeframes = { + "now": "अहिले", + "second": "एक सेकेन्ड", + "seconds": "{0} सेकण्ड", + "minute": "मिनेट", + "minutes": "{0} मिनेट", + "hour": "एक घण्टा", + "hours": "{0} घण्टा", + "day": "एक दिन", + "days": "{0} दिन", + "month": "एक महिना", + "months": "{0} महिना", + "year": "एक बर्ष", + "years": "बर्ष", + } + + meridians = {"am": "पूर्वाह्न", "pm": "अपरान्ह", "AM": "पूर्वाह्न", "PM": "अपरान्ह"} + + month_names = [ + "", + "जनवरी", + "फेब्रुअरी", + "मार्च", + "एप्रील", + "मे", + "जुन", + "जुलाई", + "अगष्ट", + "सेप्टेम्बर", + "अक्टोबर", + "नोवेम्बर", + "डिसेम्बर", + ] + month_abbreviations = [ + "", + "जन", + "फेब", + "मार्च", + "एप्रील", + "मे", + "जुन", + "जुलाई", + "अग", + "सेप", + "अक्ट", + "नोव", + "डिस", + ] + + day_names = [ + "", + "सोमवार", + "मंगलवार", + "बुधवार", + "बिहिवार", + "शुक्रवार", + "शनिवार", + "आइतवार", + ] + + day_abbreviations = ["", "सोम", "मंगल", "बुध", "बिहि", "शुक्र", "शनि", "आइत"] + + +class EstonianLocale(Locale): + names = ["ee", "et"] + + past = "{0} tagasi" + future = "{0} pärast" + and_word = "ja" + + timeframes = { + "now": {"past": "just nüüd", "future": "just nüüd"}, + "second": {"past": "üks sekund", "future": "ühe sekundi"}, + "seconds": {"past": "{0} sekundit", "future": "{0} sekundi"}, + "minute": {"past": "üks minut", "future": "ühe minuti"}, + "minutes": {"past": "{0} minutit", "future": "{0} minuti"}, + "hour": {"past": "tund aega", "future": "tunni aja"}, + "hours": {"past": "{0} tundi", "future": "{0} tunni"}, + "day": {"past": "üks päev", "future": "ühe päeva"}, + "days": {"past": "{0} päeva", "future": "{0} päeva"}, + "month": {"past": "üks kuu", "future": "ühe kuu"}, + "months": {"past": "{0} kuud", "future": "{0} kuu"}, + "year": {"past": "üks aasta", "future": "ühe aasta"}, + "years": {"past": "{0} aastat", "future": "{0} aasta"}, + } + + month_names = [ + "", + "Jaanuar", + "Veebruar", + "Märts", + "Aprill", + "Mai", + "Juuni", + "Juuli", + "August", + "September", + "Oktoober", + "November", + "Detsember", + ] + month_abbreviations = [ + "", + "Jan", + "Veb", + "Mär", + "Apr", + "Mai", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dets", + ] + + day_names = [ + "", + "Esmaspäev", + "Teisipäev", + "Kolmapäev", + "Neljapäev", + "Reede", + "Laupäev", + "Pühapäev", + ] + day_abbreviations = ["", "Esm", "Teis", "Kolm", "Nelj", "Re", "Lau", "Püh"] + + def _format_timeframe(self, timeframe, delta): + form = self.timeframes[timeframe] + if delta > 0: + form = form["future"] + else: + form = form["past"] + return form.format(abs(delta)) + + +class SwahiliLocale(Locale): + + names = [ + "sw", + "sw_ke", + "sw_tz", + ] + + past = "{0} iliyopita" + future = "muda wa {0}" + and_word = "na" + + timeframes = { + "now": "sasa hivi", + "second": "sekunde", + "seconds": "sekunde {0}", + "minute": "dakika moja", + "minutes": "dakika {0}", + "hour": "saa moja", + "hours": "saa {0}", + "day": "siku moja", + "days": "siku {0}", + "week": "wiki moja", + "weeks": "wiki {0}", + "month": "mwezi moja", + "months": "miezi {0}", + "year": "mwaka moja", + "years": "miaka {0}", + } + + meridians = {"am": "asu", "pm": "mch", "AM": "ASU", "PM": "MCH"} + + month_names = [ + "", + "Januari", + "Februari", + "Machi", + "Aprili", + "Mei", + "Juni", + "Julai", + "Agosti", + "Septemba", + "Oktoba", + "Novemba", + "Desemba", + ] + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mac", + "Apr", + "Mei", + "Jun", + "Jul", + "Ago", + "Sep", + "Okt", + "Nov", + "Des", + ] + + day_names = [ + "", + "Jumatatu", + "Jumanne", + "Jumatano", + "Alhamisi", + "Ijumaa", + "Jumamosi", + "Jumapili", + ] + day_abbreviations = [ + "", + "Jumatatu", + "Jumanne", + "Jumatano", + "Alhamisi", + "Ijumaa", + "Jumamosi", + "Jumapili", + ] + + +_locales = _map_locales() diff --git a/script.module.slyguy/resources/modules/arrow/parser.py b/script.module.slyguy/resources/modules/arrow/parser.py new file mode 100644 index 00000000..024df2fa --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/parser.py @@ -0,0 +1,557 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +import re +from datetime import datetime, timedelta + +from dateutil import tz + +from arrow import locales +from arrow.util import iso_to_gregorian, normalize_timestamp + +try: + from functools import lru_cache +except ImportError: # pragma: no cover + from _backports.functools_lru_cache import lru_cache # pragma: no cover + + +class ParserError(ValueError): + pass + + +# Allows for ParserErrors to be propagated from _build_datetime() +# when day_of_year errors occur. +# Before this, the ParserErrors were caught by the try/except in +# _parse_multiformat() and the appropriate error message was not +# transmitted to the user. +class ParserMatchError(ParserError): + pass + + +class DateTimeParser(object): + + _FORMAT_RE = re.compile( + r"(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?d?d?d|HH?|hh?|mm?|ss?|S+|ZZ?Z?|a|A|x|X|W)" + ) + _ESCAPE_RE = re.compile(r"\[[^\[\]]*\]") + + _ONE_OR_TWO_DIGIT_RE = re.compile(r"\d{1,2}") + _ONE_OR_TWO_OR_THREE_DIGIT_RE = re.compile(r"\d{1,3}") + _ONE_OR_MORE_DIGIT_RE = re.compile(r"\d+") + _TWO_DIGIT_RE = re.compile(r"\d{2}") + _THREE_DIGIT_RE = re.compile(r"\d{3}") + _FOUR_DIGIT_RE = re.compile(r"\d{4}") + _TZ_Z_RE = re.compile(r"([\+\-])(\d{2})(?:(\d{2}))?|Z") + _TZ_ZZ_RE = re.compile(r"([\+\-])(\d{2})(?:\:(\d{2}))?|Z") + _TZ_NAME_RE = re.compile(r"\w[\w+\-/]+") + # NOTE: timestamps cannot be parsed from natural language strings (by removing the ^...$) because it will + # break cases like "15 Jul 2000" and a format list (see issue #447) + _TIMESTAMP_RE = re.compile(r"^\-?\d+\.?\d+$") + _TIMESTAMP_EXPANDED_RE = re.compile(r"^\-?\d+$") + _TIME_RE = re.compile(r"^(\d{2})(?:\:?(\d{2}))?(?:\:?(\d{2}))?(?:([\.\,])(\d+))?$") + _WEEK_DATE_RE = re.compile(r"(?P\d{4})[\-]?W(?P\d{2})[\-]?(?P\d)?") + + _BASE_INPUT_RE_MAP = { + "YYYY": _FOUR_DIGIT_RE, + "YY": _TWO_DIGIT_RE, + "MM": _TWO_DIGIT_RE, + "M": _ONE_OR_TWO_DIGIT_RE, + "DDDD": _THREE_DIGIT_RE, + "DDD": _ONE_OR_TWO_OR_THREE_DIGIT_RE, + "DD": _TWO_DIGIT_RE, + "D": _ONE_OR_TWO_DIGIT_RE, + "HH": _TWO_DIGIT_RE, + "H": _ONE_OR_TWO_DIGIT_RE, + "hh": _TWO_DIGIT_RE, + "h": _ONE_OR_TWO_DIGIT_RE, + "mm": _TWO_DIGIT_RE, + "m": _ONE_OR_TWO_DIGIT_RE, + "ss": _TWO_DIGIT_RE, + "s": _ONE_OR_TWO_DIGIT_RE, + "X": _TIMESTAMP_RE, + "x": _TIMESTAMP_EXPANDED_RE, + "ZZZ": _TZ_NAME_RE, + "ZZ": _TZ_ZZ_RE, + "Z": _TZ_Z_RE, + "S": _ONE_OR_MORE_DIGIT_RE, + "W": _WEEK_DATE_RE, + } + + SEPARATORS = ["-", "/", "."] + + def __init__(self, locale="en_us", cache_size=0): + + self.locale = locales.get_locale(locale) + self._input_re_map = self._BASE_INPUT_RE_MAP.copy() + self._input_re_map.update( + { + "MMMM": self._generate_choice_re( + self.locale.month_names[1:], re.IGNORECASE + ), + "MMM": self._generate_choice_re( + self.locale.month_abbreviations[1:], re.IGNORECASE + ), + "Do": re.compile(self.locale.ordinal_day_re), + "dddd": self._generate_choice_re( + self.locale.day_names[1:], re.IGNORECASE + ), + "ddd": self._generate_choice_re( + self.locale.day_abbreviations[1:], re.IGNORECASE + ), + "d": re.compile(r"[1-7]"), + "a": self._generate_choice_re( + (self.locale.meridians["am"], self.locale.meridians["pm"]) + ), + # note: 'A' token accepts both 'am/pm' and 'AM/PM' formats to + # ensure backwards compatibility of this token + "A": self._generate_choice_re(self.locale.meridians.values()), + } + ) + if cache_size > 0: + self._generate_pattern_re = lru_cache(maxsize=cache_size)( + self._generate_pattern_re + ) + + # TODO: since we support more than ISO 8601, we should rename this function + # IDEA: break into multiple functions + def parse_iso(self, datetime_string): + # TODO: add a flag to normalize whitespace (useful in logs, ref issue #421) + has_space_divider = " " in datetime_string + has_t_divider = "T" in datetime_string + + num_spaces = datetime_string.count(" ") + if has_space_divider and num_spaces != 1 or has_t_divider and num_spaces > 0: + raise ParserError( + "Expected an ISO 8601-like string, but was given '{}'. Try passing in a format string to resolve this.".format( + datetime_string + ) + ) + + has_time = has_space_divider or has_t_divider + has_tz = False + + # date formats (ISO 8601 and others) to test against + # NOTE: YYYYMM is omitted to avoid confusion with YYMMDD (no longer part of ISO 8601, but is still often used) + formats = [ + "YYYY-MM-DD", + "YYYY-M-DD", + "YYYY-M-D", + "YYYY/MM/DD", + "YYYY/M/DD", + "YYYY/M/D", + "YYYY.MM.DD", + "YYYY.M.DD", + "YYYY.M.D", + "YYYYMMDD", + "YYYY-DDDD", + "YYYYDDDD", + "YYYY-MM", + "YYYY/MM", + "YYYY.MM", + "YYYY", + "W", + ] + + if has_time: + + if has_space_divider: + date_string, time_string = datetime_string.split(" ", 1) + else: + date_string, time_string = datetime_string.split("T", 1) + + time_parts = re.split(r"[\+\-Z]", time_string, 1, re.IGNORECASE) + + time_components = self._TIME_RE.match(time_parts[0]) + + if time_components is None: + raise ParserError( + "Invalid time component provided. Please specify a format or provide a valid time component in the basic or extended ISO 8601 time format." + ) + + ( + hours, + minutes, + seconds, + subseconds_sep, + subseconds, + ) = time_components.groups() + + has_tz = len(time_parts) == 2 + has_minutes = minutes is not None + has_seconds = seconds is not None + has_subseconds = subseconds is not None + + is_basic_time_format = ":" not in time_parts[0] + tz_format = "Z" + + # use 'ZZ' token instead since tz offset is present in non-basic format + if has_tz and ":" in time_parts[1]: + tz_format = "ZZ" + + time_sep = "" if is_basic_time_format else ":" + + if has_subseconds: + time_string = "HH{time_sep}mm{time_sep}ss{subseconds_sep}S".format( + time_sep=time_sep, subseconds_sep=subseconds_sep + ) + elif has_seconds: + time_string = "HH{time_sep}mm{time_sep}ss".format(time_sep=time_sep) + elif has_minutes: + time_string = "HH{time_sep}mm".format(time_sep=time_sep) + else: + time_string = "HH" + + if has_space_divider: + formats = ["{} {}".format(f, time_string) for f in formats] + else: + formats = ["{}T{}".format(f, time_string) for f in formats] + + if has_time and has_tz: + # Add "Z" or "ZZ" to the format strings to indicate to + # _parse_token() that a timezone needs to be parsed + formats = ["{}{}".format(f, tz_format) for f in formats] + + return self._parse_multiformat(datetime_string, formats) + + def parse(self, datetime_string, fmt): + + if isinstance(fmt, list): + return self._parse_multiformat(datetime_string, fmt) + + fmt_tokens, fmt_pattern_re = self._generate_pattern_re(fmt) + + match = fmt_pattern_re.search(datetime_string) + + if match is None: + raise ParserMatchError( + "Failed to match '{}' when parsing '{}'".format(fmt, datetime_string) + ) + + parts = {} + for token in fmt_tokens: + if token == "Do": + value = match.group("value") + elif token == "W": + value = (match.group("year"), match.group("week"), match.group("day")) + else: + value = match.group(token) + self._parse_token(token, value, parts) + + return self._build_datetime(parts) + + def _generate_pattern_re(self, fmt): + + # fmt is a string of tokens like 'YYYY-MM-DD' + # we construct a new string by replacing each + # token by its pattern: + # 'YYYY-MM-DD' -> '(?P\d{4})-(?P\d{2})-(?P
\d{2})' + tokens = [] + offset = 0 + + # Escape all special RegEx chars + escaped_fmt = re.escape(fmt) + + # Extract the bracketed expressions to be reinserted later. + escaped_fmt = re.sub(self._ESCAPE_RE, "#", escaped_fmt) + + # Any number of S is the same as one. + # TODO: allow users to specify the number of digits to parse + escaped_fmt = re.sub(r"S+", "S", escaped_fmt) + + escaped_data = re.findall(self._ESCAPE_RE, fmt) + + fmt_pattern = escaped_fmt + + for m in self._FORMAT_RE.finditer(escaped_fmt): + token = m.group(0) + try: + input_re = self._input_re_map[token] + except KeyError: + raise ParserError("Unrecognized token '{}'".format(token)) + input_pattern = "(?P<{}>{})".format(token, input_re.pattern) + tokens.append(token) + # a pattern doesn't have the same length as the token + # it replaces! We keep the difference in the offset variable. + # This works because the string is scanned left-to-right and matches + # are returned in the order found by finditer. + fmt_pattern = ( + fmt_pattern[: m.start() + offset] + + input_pattern + + fmt_pattern[m.end() + offset :] + ) + offset += len(input_pattern) - (m.end() - m.start()) + + final_fmt_pattern = "" + split_fmt = fmt_pattern.split(r"\#") + + # Due to the way Python splits, 'split_fmt' will always be longer + for i in range(len(split_fmt)): + final_fmt_pattern += split_fmt[i] + if i < len(escaped_data): + final_fmt_pattern += escaped_data[i][1:-1] + + # Wrap final_fmt_pattern in a custom word boundary to strictly + # match the formatting pattern and filter out date and time formats + # that include junk such as: blah1998-09-12 blah, blah 1998-09-12blah, + # blah1998-09-12blah. The custom word boundary matches every character + # that is not a whitespace character to allow for searching for a date + # and time string in a natural language sentence. Therefore, searching + # for a string of the form YYYY-MM-DD in "blah 1998-09-12 blah" will + # work properly. + # Certain punctuation before or after the target pattern such as + # "1998-09-12," is permitted. For the full list of valid punctuation, + # see the documentation. + + starting_word_boundary = ( + r"(?\s])" # This is the list of punctuation that is ok before the pattern (i.e. "It can't not be these characters before the pattern") + r"(\b|^)" # The \b is to block cases like 1201912 but allow 201912 for pattern YYYYMM. The ^ was necessary to allow a negative number through i.e. before epoch numbers + ) + ending_word_boundary = ( + r"(?=[\,\.\;\:\?\!\"\'\`\[\]\{\}\(\)\<\>]?" # Positive lookahead stating that these punctuation marks can appear after the pattern at most 1 time + r"(?!\S))" # Don't allow any non-whitespace character after the punctuation + ) + bounded_fmt_pattern = r"{}{}{}".format( + starting_word_boundary, final_fmt_pattern, ending_word_boundary + ) + + return tokens, re.compile(bounded_fmt_pattern, flags=re.IGNORECASE) + + def _parse_token(self, token, value, parts): + + if token == "YYYY": + parts["year"] = int(value) + + elif token == "YY": + value = int(value) + parts["year"] = 1900 + value if value > 68 else 2000 + value + + elif token in ["MMMM", "MMM"]: + parts["month"] = self.locale.month_number(value.lower()) + + elif token in ["MM", "M"]: + parts["month"] = int(value) + + elif token in ["DDDD", "DDD"]: + parts["day_of_year"] = int(value) + + elif token in ["DD", "D"]: + parts["day"] = int(value) + + elif token in ["Do"]: + parts["day"] = int(value) + + elif token.upper() in ["HH", "H"]: + parts["hour"] = int(value) + + elif token in ["mm", "m"]: + parts["minute"] = int(value) + + elif token in ["ss", "s"]: + parts["second"] = int(value) + + elif token == "S": + # We have the *most significant* digits of an arbitrary-precision integer. + # We want the six most significant digits as an integer, rounded. + # IDEA: add nanosecond support somehow? Need datetime support for it first. + value = value.ljust(7, str("0")) + + # floating-point (IEEE-754) defaults to half-to-even rounding + seventh_digit = int(value[6]) + if seventh_digit == 5: + rounding = int(value[5]) % 2 + elif seventh_digit > 5: + rounding = 1 + else: + rounding = 0 + + parts["microsecond"] = int(value[:6]) + rounding + + elif token == "X": + parts["timestamp"] = float(value) + + elif token == "x": + parts["expanded_timestamp"] = int(value) + + elif token in ["ZZZ", "ZZ", "Z"]: + parts["tzinfo"] = TzinfoParser.parse(value) + + elif token in ["a", "A"]: + if value in (self.locale.meridians["am"], self.locale.meridians["AM"]): + parts["am_pm"] = "am" + elif value in (self.locale.meridians["pm"], self.locale.meridians["PM"]): + parts["am_pm"] = "pm" + + elif token == "W": + parts["weekdate"] = value + + @staticmethod + def _build_datetime(parts): + + weekdate = parts.get("weekdate") + + if weekdate is not None: + # we can use strptime (%G, %V, %u) in python 3.6 but these tokens aren't available before that + year, week = int(weekdate[0]), int(weekdate[1]) + + if weekdate[2] is not None: + day = int(weekdate[2]) + else: + # day not given, default to 1 + day = 1 + + dt = iso_to_gregorian(year, week, day) + parts["year"] = dt.year + parts["month"] = dt.month + parts["day"] = dt.day + + timestamp = parts.get("timestamp") + + if timestamp is not None: + return datetime.fromtimestamp(timestamp, tz=tz.tzutc()) + + expanded_timestamp = parts.get("expanded_timestamp") + + if expanded_timestamp is not None: + return datetime.fromtimestamp( + normalize_timestamp(expanded_timestamp), tz=tz.tzutc(), + ) + + day_of_year = parts.get("day_of_year") + + if day_of_year is not None: + year = parts.get("year") + month = parts.get("month") + if year is None: + raise ParserError( + "Year component is required with the DDD and DDDD tokens." + ) + + if month is not None: + raise ParserError( + "Month component is not allowed with the DDD and DDDD tokens." + ) + + date_string = "{}-{}".format(year, day_of_year) + try: + dt = datetime.strptime(date_string, "%Y-%j") + except ValueError: + raise ParserError( + "The provided day of year '{}' is invalid.".format(day_of_year) + ) + + parts["year"] = dt.year + parts["month"] = dt.month + parts["day"] = dt.day + + am_pm = parts.get("am_pm") + hour = parts.get("hour", 0) + + if am_pm == "pm" and hour < 12: + hour += 12 + elif am_pm == "am" and hour == 12: + hour = 0 + + # Support for midnight at the end of day + if hour == 24: + if parts.get("minute", 0) != 0: + raise ParserError("Midnight at the end of day must not contain minutes") + if parts.get("second", 0) != 0: + raise ParserError("Midnight at the end of day must not contain seconds") + if parts.get("microsecond", 0) != 0: + raise ParserError( + "Midnight at the end of day must not contain microseconds" + ) + hour = 0 + day_increment = 1 + else: + day_increment = 0 + + # account for rounding up to 1000000 + microsecond = parts.get("microsecond", 0) + if microsecond == 1000000: + microsecond = 0 + second_increment = 1 + else: + second_increment = 0 + + increment = timedelta(days=day_increment, seconds=second_increment) + + return ( + datetime( + year=parts.get("year", 1), + month=parts.get("month", 1), + day=parts.get("day", 1), + hour=hour, + minute=parts.get("minute", 0), + second=parts.get("second", 0), + microsecond=microsecond, + tzinfo=parts.get("tzinfo"), + ) + + increment + ) + + def _parse_multiformat(self, string, formats): + + _datetime = None + + for fmt in formats: + try: + _datetime = self.parse(string, fmt) + break + except ParserMatchError: + pass + + if _datetime is None: + raise ParserError( + "Could not match input '{}' to any of the following formats: {}".format( + string, ", ".join(formats) + ) + ) + + return _datetime + + # generates a capture group of choices separated by an OR operator + @staticmethod + def _generate_choice_re(choices, flags=0): + return re.compile(r"({})".format("|".join(choices)), flags=flags) + + +class TzinfoParser(object): + _TZINFO_RE = re.compile(r"^([\+\-])?(\d{2})(?:\:?(\d{2}))?$") + + @classmethod + def parse(cls, tzinfo_string): + + tzinfo = None + + if tzinfo_string == "local": + tzinfo = tz.tzlocal() + + elif tzinfo_string in ["utc", "UTC", "Z"]: + tzinfo = tz.tzutc() + + else: + + iso_match = cls._TZINFO_RE.match(tzinfo_string) + + if iso_match: + sign, hours, minutes = iso_match.groups() + if minutes is None: + minutes = 0 + seconds = int(hours) * 3600 + int(minutes) * 60 + + if sign == "-": + seconds *= -1 + + tzinfo = tz.tzoffset(None, seconds) + + else: + tzinfo = tz.gettz(tzinfo_string) + + if tzinfo is None: + raise ParserError( + 'Could not parse timezone expression "{}"'.format(tzinfo_string) + ) + + return tzinfo diff --git a/script.module.slyguy/resources/modules/arrow/util.py b/script.module.slyguy/resources/modules/arrow/util.py new file mode 100644 index 00000000..785da867 --- /dev/null +++ b/script.module.slyguy/resources/modules/arrow/util.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +import datetime +import numbers + +from arrow.constants import MAX_TIMESTAMP, MAX_TIMESTAMP_MS, MAX_TIMESTAMP_US + + +def total_seconds(td): + """Get total seconds for timedelta.""" + return td.total_seconds() + + +def is_timestamp(value): + """Check if value is a valid timestamp.""" + if isinstance(value, bool): + return False + if not ( + isinstance(value, numbers.Integral) + or isinstance(value, float) + or isinstance(value, str) + ): + return False + try: + float(value) + return True + except ValueError: + return False + + +def normalize_timestamp(timestamp): + """Normalize millisecond and microsecond timestamps into normal timestamps.""" + if timestamp > MAX_TIMESTAMP: + if timestamp < MAX_TIMESTAMP_MS: + timestamp /= 1e3 + elif timestamp < MAX_TIMESTAMP_US: + timestamp /= 1e6 + else: + raise ValueError( + "The specified timestamp '{}' is too large.".format(timestamp) + ) + return timestamp + + +# Credit to https://stackoverflow.com/a/1700069 +def iso_to_gregorian(iso_year, iso_week, iso_day): + """Converts an ISO week date tuple into a datetime object.""" + + if not 1 <= iso_week <= 53: + raise ValueError("ISO Calendar week value must be between 1-53.") + + if not 1 <= iso_day <= 7: + raise ValueError("ISO Calendar day value must be between 1-7") + + # The first week of the year always contains 4 Jan. + fourth_jan = datetime.date(iso_year, 1, 4) + delta = datetime.timedelta(fourth_jan.isoweekday() - 1) + year_start = fourth_jan - delta + gregorian = year_start + datetime.timedelta(days=iso_day - 1, weeks=iso_week - 1) + + return gregorian + + +# Python 2.7 / 3.0+ definitions for isstr function. + +try: # pragma: no cover + basestring + + def isstr(s): + return isinstance(s, basestring) # noqa: F821 + + +except NameError: # pragma: no cover + + def isstr(s): + return isinstance(s, str) + + +__all__ = ["total_seconds", "is_timestamp", "isstr", "iso_to_gregorian"] diff --git a/script.module.slyguy/resources/modules/bs4/__init__.py b/script.module.slyguy/resources/modules/bs4/__init__.py new file mode 100644 index 00000000..eb068730 --- /dev/null +++ b/script.module.slyguy/resources/modules/bs4/__init__.py @@ -0,0 +1,465 @@ +"""Beautiful Soup +Elixir and Tonic +"The Screen-Scraper's Friend" +http://www.crummy.com/software/BeautifulSoup/ + +Beautiful Soup uses a pluggable XML or HTML parser to parse a +(possibly invalid) document into a tree representation. Beautiful Soup +provides provides methods and Pythonic idioms that make it easy to +navigate, search, and modify the parse tree. + +Beautiful Soup works with Python 2.6 and up. It works better if lxml +and/or html5lib is installed. + +For more than you ever wanted to know about Beautiful Soup, see the +documentation: +http://www.crummy.com/software/BeautifulSoup/bs4/doc/ +""" + +__author__ = "Leonard Richardson (leonardr@segfault.org)" +__version__ = "4.4.1" +__copyright__ = "Copyright (c) 2004-2015 Leonard Richardson" +__license__ = "MIT" + +__all__ = ['BeautifulSoup'] + +import os +import re +import six +import warnings + +from .builder import builder_registry, ParserRejectedMarkup +from .dammit import UnicodeDammit +from .element import ( + CData, + Comment, + DEFAULT_OUTPUT_ENCODING, + Declaration, + Doctype, + NavigableString, + PageElement, + ProcessingInstruction, + ResultSet, + SoupStrainer, + Tag, + ) + +class BeautifulSoup(Tag): + """ + This class defines the basic interface called by the tree builders. + + These methods will be called by the parser: + reset() + feed(markup) + + The tree builder may call these methods from its feed() implementation: + handle_starttag(name, attrs) # See note about return value + handle_endtag(name) + handle_data(data) # Appends to the current data node + endData(containerClass=NavigableString) # Ends the current data node + + No matter how complicated the underlying parser is, you should be + able to build a tree using 'start tag' events, 'end tag' events, + 'data' events, and "done with data" events. + + If you encounter an empty-element tag (aka a self-closing tag, + like HTML's
tag), call handle_starttag and then + handle_endtag. + """ + ROOT_TAG_NAME = u'[document]' + + # If the end-user gives no indication which tree builder they + # want, look for one with these features. + DEFAULT_BUILDER_FEATURES = ['html', 'fast'] + + ASCII_SPACES = '\x20\x0a\x09\x0c\x0d' + + NO_PARSER_SPECIFIED_WARNING = "No parser was explicitly specified, so I'm using the best available %(markup_type)s parser for this system (\"%(parser)s\"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.\n\nTo get rid of this warning, change this:\n\n BeautifulSoup([your markup])\n\nto this:\n\n BeautifulSoup([your markup], \"%(parser)s\")\n" + + def __init__(self, markup="", features=None, builder=None, + parse_only=None, from_encoding=None, exclude_encodings=None, + **kwargs): + """The Soup object is initialized as the 'root tag', and the + provided markup (which can be a string or a file-like object) + is fed into the underlying parser.""" + + if 'convertEntities' in kwargs: + warnings.warn( + "BS4 does not respect the convertEntities argument to the " + "BeautifulSoup constructor. Entities are always converted " + "to Unicode characters.") + + if 'markupMassage' in kwargs: + del kwargs['markupMassage'] + warnings.warn( + "BS4 does not respect the markupMassage argument to the " + "BeautifulSoup constructor. The tree builder is responsible " + "for any necessary markup massage.") + + if 'smartQuotesTo' in kwargs: + del kwargs['smartQuotesTo'] + warnings.warn( + "BS4 does not respect the smartQuotesTo argument to the " + "BeautifulSoup constructor. Smart quotes are always converted " + "to Unicode characters.") + + if 'selfClosingTags' in kwargs: + del kwargs['selfClosingTags'] + warnings.warn( + "BS4 does not respect the selfClosingTags argument to the " + "BeautifulSoup constructor. The tree builder is responsible " + "for understanding self-closing tags.") + + if 'isHTML' in kwargs: + del kwargs['isHTML'] + warnings.warn( + "BS4 does not respect the isHTML argument to the " + "BeautifulSoup constructor. Suggest you use " + "features='lxml' for HTML and features='lxml-xml' for " + "XML.") + + def deprecated_argument(old_name, new_name): + if old_name in kwargs: + warnings.warn( + 'The "%s" argument to the BeautifulSoup constructor ' + 'has been renamed to "%s."' % (old_name, new_name)) + value = kwargs[old_name] + del kwargs[old_name] + return value + return None + + parse_only = parse_only or deprecated_argument( + "parseOnlyThese", "parse_only") + + from_encoding = from_encoding or deprecated_argument( + "fromEncoding", "from_encoding") + + if len(kwargs) > 0: + arg = list(kwargs.keys()).pop() + raise TypeError( + "__init__() got an unexpected keyword argument '%s'" % arg) + + if builder is None: + original_features = features + if isinstance(features, six.string_types): + features = [features] + if features is None or len(features) == 0: + features = self.DEFAULT_BUILDER_FEATURES + builder_class = builder_registry.lookup(*features) + if builder_class is None: + raise FeatureNotFound( + "Couldn't find a tree builder with the features you " + "requested: %s. Do you need to install a parser library?" + % ",".join(features)) + builder = builder_class() + if not (original_features == builder.NAME or + original_features in builder.ALTERNATE_NAMES): + if builder.is_xml: + markup_type = "XML" + else: + markup_type = "HTML" + warnings.warn(self.NO_PARSER_SPECIFIED_WARNING % dict( + parser=builder.NAME, + markup_type=markup_type)) + + self.builder = builder + self.is_xml = builder.is_xml + self.builder.soup = self + + self.parse_only = parse_only + + if hasattr(markup, 'read'): # It's a file-type object. + markup = markup.read() + elif len(markup) <= 256: + # Print out warnings for a couple beginner problems + # involving passing non-markup to Beautiful Soup. + # Beautiful Soup will still parse the input as markup, + # just in case that's what the user really wants. + if (isinstance(markup, six.text_type) + and not os.path.supports_unicode_filenames): + possible_filename = markup.encode("utf8") + else: + possible_filename = markup + is_file = False + try: + is_file = os.path.exists(possible_filename) + except Exception as e: + # This is almost certainly a problem involving + # characters not valid in filenames on this + # system. Just let it go. + pass + if is_file: + if isinstance(markup, six.text_type): + markup = markup.encode("utf8") + warnings.warn( + '"%s" looks like a filename, not markup. You should probably open this file and pass the filehandle into Beautiful Soup.' % markup) + if markup[:5] == "http:" or markup[:6] == "https:": + # TODO: This is ugly but I couldn't get it to work in + # Python 3 otherwise. + if ((isinstance(markup, bytes) and not b' ' in markup) + or (isinstance(markup, six.text_type) and not u' ' in markup)): + if isinstance(markup, six.text_type): + markup = markup.encode("utf8") + warnings.warn( + '"%s" looks like a URL. Beautiful Soup is not an HTTP client. You should probably use an HTTP client to get the document behind the URL, and feed that document to Beautiful Soup.' % markup) + + for (self.markup, self.original_encoding, self.declared_html_encoding, + self.contains_replacement_characters) in ( + self.builder.prepare_markup( + markup, from_encoding, exclude_encodings=exclude_encodings)): + self.reset() + try: + self._feed() + break + except ParserRejectedMarkup: + pass + + # Clear out the markup and remove the builder's circular + # reference to this object. + self.markup = None + self.builder.soup = None + + def __copy__(self): + return type(self)(self.encode(), builder=self.builder) + + def __getstate__(self): + # Frequently a tree builder can't be pickled. + d = dict(self.__dict__) + if 'builder' in d and not self.builder.picklable: + del d['builder'] + return d + + def _feed(self): + # Convert the document to Unicode. + self.builder.reset() + + self.builder.feed(self.markup) + # Close out any unfinished strings and close all the open tags. + self.endData() + while self.currentTag.name != self.ROOT_TAG_NAME: + self.popTag() + + def reset(self): + Tag.__init__(self, self, self.builder, self.ROOT_TAG_NAME) + self.hidden = 1 + self.builder.reset() + self.current_data = [] + self.currentTag = None + self.tagStack = [] + self.preserve_whitespace_tag_stack = [] + self.pushTag(self) + + def new_tag(self, name, namespace=None, nsprefix=None, **attrs): + """Create a new tag associated with this soup.""" + return Tag(None, self.builder, name, namespace, nsprefix, attrs) + + def new_string(self, s, subclass=NavigableString): + """Create a new NavigableString associated with this soup.""" + return subclass(s) + + def insert_before(self, successor): + raise NotImplementedError("BeautifulSoup objects don't support insert_before().") + + def insert_after(self, successor): + raise NotImplementedError("BeautifulSoup objects don't support insert_after().") + + def popTag(self): + tag = self.tagStack.pop() + if self.preserve_whitespace_tag_stack and tag == self.preserve_whitespace_tag_stack[-1]: + self.preserve_whitespace_tag_stack.pop() + #print "Pop", tag.name + if self.tagStack: + self.currentTag = self.tagStack[-1] + return self.currentTag + + def pushTag(self, tag): + #print "Push", tag.name + if self.currentTag: + self.currentTag.contents.append(tag) + self.tagStack.append(tag) + self.currentTag = self.tagStack[-1] + if tag.name in self.builder.preserve_whitespace_tags: + self.preserve_whitespace_tag_stack.append(tag) + + def endData(self, containerClass=NavigableString): + if self.current_data: + current_data = u''.join(self.current_data) + # If whitespace is not preserved, and this string contains + # nothing but ASCII spaces, replace it with a single space + # or newline. + if not self.preserve_whitespace_tag_stack: + strippable = True + for i in current_data: + if i not in self.ASCII_SPACES: + strippable = False + break + if strippable: + if '\n' in current_data: + current_data = '\n' + else: + current_data = ' ' + + # Reset the data collector. + self.current_data = [] + + # Should we add this string to the tree at all? + if self.parse_only and len(self.tagStack) <= 1 and \ + (not self.parse_only.text or \ + not self.parse_only.search(current_data)): + return + + o = containerClass(current_data) + self.object_was_parsed(o) + + def object_was_parsed(self, o, parent=None, most_recent_element=None): + """Add an object to the parse tree.""" + parent = parent or self.currentTag + previous_element = most_recent_element or self._most_recent_element + + next_element = previous_sibling = next_sibling = None + if isinstance(o, Tag): + next_element = o.next_element + next_sibling = o.next_sibling + previous_sibling = o.previous_sibling + if not previous_element: + previous_element = o.previous_element + + o.setup(parent, previous_element, next_element, previous_sibling, next_sibling) + + self._most_recent_element = o + parent.contents.append(o) + + if parent.next_sibling: + # This node is being inserted into an element that has + # already been parsed. Deal with any dangling references. + index = parent.contents.index(o) + if index == 0: + previous_element = parent + previous_sibling = None + else: + previous_element = previous_sibling = parent.contents[index-1] + if index == len(parent.contents)-1: + next_element = parent.next_sibling + next_sibling = None + else: + next_element = next_sibling = parent.contents[index+1] + + o.previous_element = previous_element + if previous_element: + previous_element.next_element = o + o.next_element = next_element + if next_element: + next_element.previous_element = o + o.next_sibling = next_sibling + if next_sibling: + next_sibling.previous_sibling = o + o.previous_sibling = previous_sibling + if previous_sibling: + previous_sibling.next_sibling = o + + def _popToTag(self, name, nsprefix=None, inclusivePop=True): + """Pops the tag stack up to and including the most recent + instance of the given tag. If inclusivePop is false, pops the tag + stack up to but *not* including the most recent instqance of + the given tag.""" + #print "Popping to %s" % name + if name == self.ROOT_TAG_NAME: + # The BeautifulSoup object itself can never be popped. + return + + most_recently_popped = None + + stack_size = len(self.tagStack) + for i in range(stack_size - 1, 0, -1): + t = self.tagStack[i] + if (name == t.name and nsprefix == t.prefix): + if inclusivePop: + most_recently_popped = self.popTag() + break + most_recently_popped = self.popTag() + + return most_recently_popped + + def handle_starttag(self, name, namespace, nsprefix, attrs): + """Push a start tag on to the stack. + + If this method returns None, the tag was rejected by the + SoupStrainer. You should proceed as if the tag had not occured + in the document. For instance, if this was a self-closing tag, + don't call handle_endtag. + """ + + # print "Start tag %s: %s" % (name, attrs) + self.endData() + + if (self.parse_only and len(self.tagStack) <= 1 + and (self.parse_only.text + or not self.parse_only.search_tag(name, attrs))): + return None + + tag = Tag(self, self.builder, name, namespace, nsprefix, attrs, + self.currentTag, self._most_recent_element) + if tag is None: + return tag + if self._most_recent_element: + self._most_recent_element.next_element = tag + self._most_recent_element = tag + self.pushTag(tag) + return tag + + def handle_endtag(self, name, nsprefix=None): + #print "End tag: " + name + self.endData() + self._popToTag(name, nsprefix) + + def handle_data(self, data): + self.current_data.append(data) + + def decode(self, pretty_print=False, + eventual_encoding=DEFAULT_OUTPUT_ENCODING, + formatter="minimal"): + """Returns a string or Unicode representation of this document. + To get Unicode, pass None for encoding.""" + + if self.is_xml: + # Print the XML declaration + encoding_part = '' + if eventual_encoding != None: + encoding_part = ' encoding="%s"' % eventual_encoding + prefix = u'\n' % encoding_part + else: + prefix = u'' + if not pretty_print: + indent_level = None + else: + indent_level = 0 + return prefix + super(BeautifulSoup, self).decode( + indent_level, eventual_encoding, formatter) + +# Alias to make it easier to type import: 'from bs4 import _soup' +_s = BeautifulSoup +_soup = BeautifulSoup + +class BeautifulStoneSoup(BeautifulSoup): + """Deprecated interface to an XML parser.""" + + def __init__(self, *args, **kwargs): + kwargs['features'] = 'xml' + warnings.warn( + 'The BeautifulStoneSoup class is deprecated. Instead of using ' + 'it, pass features="xml" into the BeautifulSoup constructor.') + super(BeautifulStoneSoup, self).__init__(*args, **kwargs) + + +class StopParsing(Exception): + pass + +class FeatureNotFound(ValueError): + pass + + +#By default, act as an HTML pretty-printer. +if __name__ == '__main__': + import sys + soup = BeautifulSoup(sys.stdin) + print(soup.prettify()) diff --git a/script.module.slyguy/resources/modules/bs4/builder/__init__.py b/script.module.slyguy/resources/modules/bs4/builder/__init__.py new file mode 100644 index 00000000..b6e0ffbb --- /dev/null +++ b/script.module.slyguy/resources/modules/bs4/builder/__init__.py @@ -0,0 +1,313 @@ +from collections import defaultdict +import itertools +import sys +import six +from bs4.element import ( + CharsetMetaAttributeValue, + ContentMetaAttributeValue, + whitespace_re + ) + +__all__ = [ + 'HTMLTreeBuilder', + 'SAXTreeBuilder', + 'TreeBuilder', + 'TreeBuilderRegistry', + ] + +# Some useful features for a TreeBuilder to have. +FAST = 'fast' +PERMISSIVE = 'permissive' +STRICT = 'strict' +XML = 'xml' +HTML = 'html' +HTML_5 = 'html5' + + +class TreeBuilderRegistry(object): + + def __init__(self): + self.builders_for_feature = defaultdict(list) + self.builders = [] + + def register(self, treebuilder_class): + """Register a treebuilder based on its advertised features.""" + for feature in treebuilder_class.features: + self.builders_for_feature[feature].insert(0, treebuilder_class) + self.builders.insert(0, treebuilder_class) + + def lookup(self, *features): + if len(self.builders) == 0: + # There are no builders at all. + return None + + if len(features) == 0: + # They didn't ask for any features. Give them the most + # recently registered builder. + return self.builders[0] + + # Go down the list of features in order, and eliminate any builders + # that don't match every feature. + features = list(features) + features.reverse() + candidates = None + candidate_set = None + while len(features) > 0: + feature = features.pop() + we_have_the_feature = self.builders_for_feature.get(feature, []) + if len(we_have_the_feature) > 0: + if candidates is None: + candidates = we_have_the_feature + candidate_set = set(candidates) + else: + # Eliminate any candidates that don't have this feature. + candidate_set = candidate_set.intersection( + set(we_have_the_feature)) + + # The only valid candidates are the ones in candidate_set. + # Go through the original list of candidates and pick the first one + # that's in candidate_set. + if candidate_set is None: + return None + for candidate in candidates: + if candidate in candidate_set: + return candidate + return None + +# The BeautifulSoup class will take feature lists from developers and use them +# to look up builders in this registry. +builder_registry = TreeBuilderRegistry() + +class TreeBuilder(object): + """Turn a document into a Beautiful Soup object tree.""" + + NAME = "[Unknown tree builder]" + ALTERNATE_NAMES = [] + features = [] + + is_xml = False + picklable = False + preserve_whitespace_tags = set() + empty_element_tags = None # A tag will be considered an empty-element + # tag when and only when it has no contents. + + # A value for these tag/attribute combinations is a space- or + # comma-separated list of CDATA, rather than a single CDATA. + cdata_list_attributes = {} + + + def __init__(self): + self.soup = None + + def reset(self): + pass + + def can_be_empty_element(self, tag_name): + """Might a tag with this name be an empty-element tag? + + The final markup may or may not actually present this tag as + self-closing. + + For instance: an HTMLBuilder does not consider a

tag to be + an empty-element tag (it's not in + HTMLBuilder.empty_element_tags). This means an empty

tag + will be presented as "

", not "

". + + The default implementation has no opinion about which tags are + empty-element tags, so a tag will be presented as an + empty-element tag if and only if it has no contents. + "" will become "", and "bar" will + be left alone. + """ + if self.empty_element_tags is None: + return True + return tag_name in self.empty_element_tags + + def feed(self, markup): + raise NotImplementedError() + + def prepare_markup(self, markup, user_specified_encoding=None, + document_declared_encoding=None): + return markup, None, None, False + + def test_fragment_to_document(self, fragment): + """Wrap an HTML fragment to make it look like a document. + + Different parsers do this differently. For instance, lxml + introduces an empty tag, and html5lib + doesn't. Abstracting this away lets us write simple tests + which run HTML fragments through the parser and compare the + results against other HTML fragments. + + This method should not be used outside of tests. + """ + return fragment + + def set_up_substitutions(self, tag): + return False + + def _replace_cdata_list_attribute_values(self, tag_name, attrs): + """Replaces class="foo bar" with class=["foo", "bar"] + + Modifies its input in place. + """ + if not attrs: + return attrs + if self.cdata_list_attributes: + universal = self.cdata_list_attributes.get('*', []) + tag_specific = self.cdata_list_attributes.get( + tag_name.lower(), None) + for attr in list(attrs.keys()): + if attr in universal or (tag_specific and attr in tag_specific): + # We have a "class"-type attribute whose string + # value is a whitespace-separated list of + # values. Split it into a list. + value = attrs[attr] + if isinstance(value, six.string_types): + values = whitespace_re.split(value) + else: + # html5lib sometimes calls setAttributes twice + # for the same tag when rearranging the parse + # tree. On the second call the attribute value + # here is already a list. If this happens, + # leave the value alone rather than trying to + # split it again. + values = value + attrs[attr] = values + return attrs + +class SAXTreeBuilder(TreeBuilder): + """A Beautiful Soup treebuilder that listens for SAX events.""" + + def feed(self, markup): + raise NotImplementedError() + + def close(self): + pass + + def startElement(self, name, attrs): + attrs = dict((key[1], value) for key, value in list(attrs.items())) + #print "Start %s, %r" % (name, attrs) + self.soup.handle_starttag(name, attrs) + + def endElement(self, name): + #print "End %s" % name + self.soup.handle_endtag(name) + + def startElementNS(self, nsTuple, nodeName, attrs): + # Throw away (ns, nodeName) for now. + self.startElement(nodeName, attrs) + + def endElementNS(self, nsTuple, nodeName): + # Throw away (ns, nodeName) for now. + self.endElement(nodeName) + #handler.endElementNS((ns, node.nodeName), node.nodeName) + + def startPrefixMapping(self, prefix, nodeValue): + # Ignore the prefix for now. + pass + + def endPrefixMapping(self, prefix): + # Ignore the prefix for now. + # handler.endPrefixMapping(prefix) + pass + + def characters(self, content): + self.soup.handle_data(content) + + def startDocument(self): + pass + + def endDocument(self): + pass + + +class HTMLTreeBuilder(TreeBuilder): + """This TreeBuilder knows facts about HTML. + + Such as which tags are empty-element tags. + """ + + preserve_whitespace_tags = set(['pre', 'textarea']) + empty_element_tags = set(['br' , 'hr', 'input', 'img', 'meta', + 'spacer', 'link', 'frame', 'base']) + + # The HTML standard defines these attributes as containing a + # space-separated list of values, not a single value. That is, + # class="foo bar" means that the 'class' attribute has two values, + # 'foo' and 'bar', not the single value 'foo bar'. When we + # encounter one of these attributes, we will parse its value into + # a list of values if possible. Upon output, the list will be + # converted back into a string. + cdata_list_attributes = { + "*" : ['class', 'accesskey', 'dropzone'], + "a" : ['rel', 'rev'], + "link" : ['rel', 'rev'], + "td" : ["headers"], + "th" : ["headers"], + "td" : ["headers"], + "form" : ["accept-charset"], + "object" : ["archive"], + + # These are HTML5 specific, as are *.accesskey and *.dropzone above. + "area" : ["rel"], + "icon" : ["sizes"], + "iframe" : ["sandbox"], + "output" : ["for"], + } + + def set_up_substitutions(self, tag): + # We are only interested in tags + if tag.name != 'meta': + return False + + http_equiv = tag.get('http-equiv') + content = tag.get('content') + charset = tag.get('charset') + + # We are interested in tags that say what encoding the + # document was originally in. This means HTML 5-style + # tags that provide the "charset" attribute. It also means + # HTML 4-style tags that provide the "content" + # attribute and have "http-equiv" set to "content-type". + # + # In both cases we will replace the value of the appropriate + # attribute with a standin object that can take on any + # encoding. + meta_encoding = None + if charset is not None: + # HTML 5 style: + # + meta_encoding = charset + tag['charset'] = CharsetMetaAttributeValue(charset) + + elif (content is not None and http_equiv is not None + and http_equiv.lower() == 'content-type'): + # HTML 4 style: + # + tag['content'] = ContentMetaAttributeValue(content) + + return (meta_encoding is not None) + +def register_treebuilders_from(module): + """Copy TreeBuilders from the given module into this module.""" + # I'm fairly sure this is not the best way to do this. + this_module = sys.modules['bs4.builder'] + for name in module.__all__: + obj = getattr(module, name) + + if issubclass(obj, TreeBuilder): + setattr(this_module, name, obj) + this_module.__all__.append(name) + # Register the builder while we're at it. + this_module.builder_registry.register(obj) + +class ParserRejectedMarkup(Exception): + pass + +# Builders are registered in reverse order of priority, so that custom +# builder registrations will take precedence. In general, we want lxml +# to take precedence over html5lib, because it's faster. And we only +# want to use HTMLParser as a last result. +from . import _htmlparser +register_treebuilders_from(_htmlparser) \ No newline at end of file diff --git a/script.module.slyguy/resources/modules/bs4/builder/_htmlparser.py b/script.module.slyguy/resources/modules/bs4/builder/_htmlparser.py new file mode 100644 index 00000000..cb87ebae --- /dev/null +++ b/script.module.slyguy/resources/modules/bs4/builder/_htmlparser.py @@ -0,0 +1,263 @@ +"""Use the HTMLParser library to parse HTML files that aren't too bad.""" + +__all__ = [ + 'HTMLParserTreeBuilder', + ] + +from six.moves.html_parser import HTMLParser + +try: + from HTMLParser import HTMLParseError +except ImportError as e: + # HTMLParseError is removed in Python 3.5. Since it can never be + # thrown in 3.5, we can just define our own class as a placeholder. + class HTMLParseError(Exception): + pass + +import sys +import six +import warnings + +# Starting in Python 3.2, the HTMLParser constructor takes a 'strict' +# argument, which we'd like to set to False. Unfortunately, +# http://bugs.python.org/issue13273 makes strict=True a better bet +# before Python 3.2.3. +# +# At the end of this file, we monkeypatch HTMLParser so that +# strict=True works well on Python 3.2.2. +major, minor, release = sys.version_info[:3] +CONSTRUCTOR_TAKES_STRICT = major == 3 and minor == 2 and release >= 3 +CONSTRUCTOR_STRICT_IS_DEPRECATED = major == 3 and minor == 3 +CONSTRUCTOR_TAKES_CONVERT_CHARREFS = major == 3 and minor >= 4 + + +from bs4.element import ( + CData, + Comment, + Declaration, + Doctype, + ProcessingInstruction, + ) +from bs4.dammit import EntitySubstitution, UnicodeDammit + +from bs4.builder import ( + HTML, + HTMLTreeBuilder, + STRICT, + ) + + +HTMLPARSER = 'html.parser' + +class BeautifulSoupHTMLParser(HTMLParser): + def handle_starttag(self, name, attrs): + # XXX namespace + attr_dict = {} + for key, value in attrs: + # Change None attribute values to the empty string + # for consistency with the other tree builders. + if value is None: + value = '' + attr_dict[key] = value + attrvalue = '""' + self.soup.handle_starttag(name, None, None, attr_dict) + + def handle_endtag(self, name): + self.soup.handle_endtag(name) + + def handle_data(self, data): + self.soup.handle_data(data) + + def handle_charref(self, name): + # XXX workaround for a bug in HTMLParser. Remove this once + # it's fixed in all supported versions. + # http://bugs.python.org/issue13633 + if name.startswith('x'): + real_name = int(name.lstrip('x'), 16) + elif name.startswith('X'): + real_name = int(name.lstrip('X'), 16) + else: + real_name = int(name) + + try: + data = six.unichr(real_name) + except (ValueError, OverflowError) as e: + data = u"\N{REPLACEMENT CHARACTER}" + + self.handle_data(data) + + def handle_entityref(self, name): + character = EntitySubstitution.HTML_ENTITY_TO_CHARACTER.get(name) + if character is not None: + data = character + else: + data = "&%s;" % name + self.handle_data(data) + + def handle_comment(self, data): + self.soup.endData() + self.soup.handle_data(data) + self.soup.endData(Comment) + + def handle_decl(self, data): + self.soup.endData() + if data.startswith("DOCTYPE "): + data = data[len("DOCTYPE "):] + elif data == 'DOCTYPE': + # i.e. "" + data = '' + self.soup.handle_data(data) + self.soup.endData(Doctype) + + def unknown_decl(self, data): + if data.upper().startswith('CDATA['): + cls = CData + data = data[len('CDATA['):] + else: + cls = Declaration + self.soup.endData() + self.soup.handle_data(data) + self.soup.endData(cls) + + def handle_pi(self, data): + self.soup.endData() + self.soup.handle_data(data) + self.soup.endData(ProcessingInstruction) + + +class HTMLParserTreeBuilder(HTMLTreeBuilder): + + is_xml = False + picklable = True + NAME = HTMLPARSER + features = [NAME, HTML, STRICT] + + def __init__(self, *args, **kwargs): + if CONSTRUCTOR_TAKES_STRICT and not CONSTRUCTOR_STRICT_IS_DEPRECATED: + kwargs['strict'] = False + if CONSTRUCTOR_TAKES_CONVERT_CHARREFS: + kwargs['convert_charrefs'] = False + self.parser_args = (args, kwargs) + + def prepare_markup(self, markup, user_specified_encoding=None, + document_declared_encoding=None, exclude_encodings=None): + """ + :return: A 4-tuple (markup, original encoding, encoding + declared within markup, whether any characters had to be + replaced with REPLACEMENT CHARACTER). + """ + if isinstance(markup, six.text_type): + yield (markup, None, None, False) + return + + try_encodings = [user_specified_encoding, document_declared_encoding] + dammit = UnicodeDammit(markup, try_encodings, is_html=True, + exclude_encodings=exclude_encodings) + yield (dammit.markup, dammit.original_encoding, + dammit.declared_html_encoding, + dammit.contains_replacement_characters) + + def feed(self, markup): + args, kwargs = self.parser_args + parser = BeautifulSoupHTMLParser(*args, **kwargs) + parser.soup = self.soup + try: + parser.feed(markup) + except HTMLParseError as e: + warnings.warn(RuntimeWarning( + "Python's built-in HTMLParser cannot parse the given document. This is not a bug in Beautiful Soup. The best solution is to install an external parser (lxml or html5lib), and use Beautiful Soup with that parser. See http://www.crummy.com/software/BeautifulSoup/bs4/doc/#installing-a-parser for help.")) + raise e + +# Patch 3.2 versions of HTMLParser earlier than 3.2.3 to use some +# 3.2.3 code. This ensures they don't treat markup like

as a +# string. +# +# XXX This code can be removed once most Python 3 users are on 3.2.3. +if major == 3 and minor == 2 and not CONSTRUCTOR_TAKES_STRICT: + import re + attrfind_tolerant = re.compile( + r'\s*((?<=[\'"\s])[^\s/>][^\s/=>]*)(\s*=+\s*' + r'(\'[^\']*\'|"[^"]*"|(?![\'"])[^>\s]*))?') + HTMLParserTreeBuilder.attrfind_tolerant = attrfind_tolerant + + locatestarttagend = re.compile(r""" + <[a-zA-Z][-.a-zA-Z0-9:_]* # tag name + (?:\s+ # whitespace before attribute name + (?:[a-zA-Z_][-.:a-zA-Z0-9_]* # attribute name + (?:\s*=\s* # value indicator + (?:'[^']*' # LITA-enclosed value + |\"[^\"]*\" # LIT-enclosed value + |[^'\">\s]+ # bare value + ) + )? + ) + )* + \s* # trailing whitespace +""", re.VERBOSE) + BeautifulSoupHTMLParser.locatestarttagend = locatestarttagend + + from html.parser import tagfind, attrfind + + def parse_starttag(self, i): + self.__starttag_text = None + endpos = self.check_for_whole_start_tag(i) + if endpos < 0: + return endpos + rawdata = self.rawdata + self.__starttag_text = rawdata[i:endpos] + + # Now parse the data between i+1 and j into a tag and attrs + attrs = [] + match = tagfind.match(rawdata, i+1) + assert match, 'unexpected call to parse_starttag()' + k = match.end() + self.lasttag = tag = rawdata[i+1:k].lower() + while k < endpos: + if self.strict: + m = attrfind.match(rawdata, k) + else: + m = attrfind_tolerant.match(rawdata, k) + if not m: + break + attrname, rest, attrvalue = m.group(1, 2, 3) + if not rest: + attrvalue = None + elif attrvalue[:1] == '\'' == attrvalue[-1:] or \ + attrvalue[:1] == '"' == attrvalue[-1:]: + attrvalue = attrvalue[1:-1] + if attrvalue: + attrvalue = self.unescape(attrvalue) + attrs.append((attrname.lower(), attrvalue)) + k = m.end() + + end = rawdata[k:endpos].strip() + if end not in (">", "/>"): + lineno, offset = self.getpos() + if "\n" in self.__starttag_text: + lineno = lineno + self.__starttag_text.count("\n") + offset = len(self.__starttag_text) \ + - self.__starttag_text.rfind("\n") + else: + offset = offset + len(self.__starttag_text) + if self.strict: + self.error("junk characters in start tag: %r" + % (rawdata[k:endpos][:20],)) + self.handle_data(rawdata[i:endpos]) + return endpos + if end.endswith('/>'): + # XHTML-style empty tag: + self.handle_startendtag(tag, attrs) + else: + self.handle_starttag(tag, attrs) + if tag in self.CDATA_CONTENT_ELEMENTS: + self.set_cdata_mode(tag) + return endpos + + def set_cdata_mode(self, elem): + self.cdata_elem = elem.lower() + self.interesting = re.compile(r'' % self.cdata_elem, re.I) + + BeautifulSoupHTMLParser.parse_starttag = parse_starttag + BeautifulSoupHTMLParser.set_cdata_mode = set_cdata_mode + + CONSTRUCTOR_TAKES_STRICT = True diff --git a/script.module.slyguy/resources/modules/bs4/dammit.py b/script.module.slyguy/resources/modules/bs4/dammit.py new file mode 100644 index 00000000..63a51c22 --- /dev/null +++ b/script.module.slyguy/resources/modules/bs4/dammit.py @@ -0,0 +1,841 @@ +# -*- coding: utf-8 -*- +"""Beautiful Soup bonus library: Unicode, Dammit + +This library converts a bytestream to Unicode through any means +necessary. It is heavily based on code from Mark Pilgrim's Universal +Feed Parser. It works best on XML and HTML, but it does not rewrite the +XML or HTML to reflect a new encoding; that's the tree builder's job. +""" +__license__ = "MIT" + +from pdb import set_trace +import codecs +import six +from six.moves.html_entities import codepoint2name +import re +import logging +import string + +# Import a library to autodetect character encodings. +chardet_type = None +try: + # First try the fast C implementation. + # PyPI package: cchardet + import cchardet + def chardet_dammit(s): + return cchardet.detect(s)['encoding'] +except ImportError: + try: + # Fall back to the pure Python implementation + # Debian package: python-chardet + # PyPI package: chardet + import chardet + def chardet_dammit(s): + return chardet.detect(s)['encoding'] + #import chardet.constants + #chardet.constants._debug = 1 + except ImportError: + # No chardet available. + def chardet_dammit(s): + return None + +# Available from http://cjkpython.i18n.org/. +try: + import iconv_codec +except ImportError: + pass + +xml_encoding_re = re.compile( + '^<\?.*encoding=[\'"](.*?)[\'"].*\?>'.encode(), re.I) +html_meta_re = re.compile( + '<\s*meta[^>]+charset\s*=\s*["\']?([^>]*?)[ /;\'">]'.encode(), re.I) + +class EntitySubstitution(object): + + """Substitute XML or HTML entities for the corresponding characters.""" + + def _populate_class_variables(): + lookup = {} + reverse_lookup = {} + characters_for_re = [] + for codepoint, name in list(codepoint2name.items()): + character = six.unichr(codepoint) + if codepoint != 34: + # There's no point in turning the quotation mark into + # ", unless it happens within an attribute value, which + # is handled elsewhere. + characters_for_re.append(character) + lookup[character] = name + # But we do want to turn " into the quotation mark. + reverse_lookup[name] = character + re_definition = "[%s]" % "".join(characters_for_re) + return lookup, reverse_lookup, re.compile(re_definition) + (CHARACTER_TO_HTML_ENTITY, HTML_ENTITY_TO_CHARACTER, + CHARACTER_TO_HTML_ENTITY_RE) = _populate_class_variables() + + CHARACTER_TO_XML_ENTITY = { + "'": "apos", + '"': "quot", + "&": "amp", + "<": "lt", + ">": "gt", + } + + BARE_AMPERSAND_OR_BRACKET = re.compile("([<>]|" + "&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)" + ")") + + AMPERSAND_OR_BRACKET = re.compile("([<>&])") + + @classmethod + def _substitute_html_entity(cls, matchobj): + entity = cls.CHARACTER_TO_HTML_ENTITY.get(matchobj.group(0)) + return "&%s;" % entity + + @classmethod + def _substitute_xml_entity(cls, matchobj): + """Used with a regular expression to substitute the + appropriate XML entity for an XML special character.""" + entity = cls.CHARACTER_TO_XML_ENTITY[matchobj.group(0)] + return "&%s;" % entity + + @classmethod + def quoted_attribute_value(self, value): + """Make a value into a quoted XML attribute, possibly escaping it. + + Most strings will be quoted using double quotes. + + Bob's Bar -> "Bob's Bar" + + If a string contains double quotes, it will be quoted using + single quotes. + + Welcome to "my bar" -> 'Welcome to "my bar"' + + If a string contains both single and double quotes, the + double quotes will be escaped, and the string will be quoted + using double quotes. + + Welcome to "Bob's Bar" -> "Welcome to "Bob's bar" + """ + quote_with = '"' + if '"' in value: + if "'" in value: + # The string contains both single and double + # quotes. Turn the double quotes into + # entities. We quote the double quotes rather than + # the single quotes because the entity name is + # """ whether this is HTML or XML. If we + # quoted the single quotes, we'd have to decide + # between ' and &squot;. + replace_with = """ + value = value.replace('"', replace_with) + else: + # There are double quotes but no single quotes. + # We can use single quotes to quote the attribute. + quote_with = "'" + return quote_with + value + quote_with + + @classmethod + def substitute_xml(cls, value, make_quoted_attribute=False): + """Substitute XML entities for special XML characters. + + :param value: A string to be substituted. The less-than sign + will become <, the greater-than sign will become >, + and any ampersands will become &. If you want ampersands + that appear to be part of an entity definition to be left + alone, use substitute_xml_containing_entities() instead. + + :param make_quoted_attribute: If True, then the string will be + quoted, as befits an attribute value. + """ + # Escape angle brackets and ampersands. + value = cls.AMPERSAND_OR_BRACKET.sub( + cls._substitute_xml_entity, value) + + if make_quoted_attribute: + value = cls.quoted_attribute_value(value) + return value + + @classmethod + def substitute_xml_containing_entities( + cls, value, make_quoted_attribute=False): + """Substitute XML entities for special XML characters. + + :param value: A string to be substituted. The less-than sign will + become <, the greater-than sign will become >, and any + ampersands that are not part of an entity defition will + become &. + + :param make_quoted_attribute: If True, then the string will be + quoted, as befits an attribute value. + """ + # Escape angle brackets, and ampersands that aren't part of + # entities. + value = cls.BARE_AMPERSAND_OR_BRACKET.sub( + cls._substitute_xml_entity, value) + + if make_quoted_attribute: + value = cls.quoted_attribute_value(value) + return value + + @classmethod + def substitute_html(cls, s): + """Replace certain Unicode characters with named HTML entities. + + This differs from data.encode(encoding, 'xmlcharrefreplace') + in that the goal is to make the result more readable (to those + with ASCII displays) rather than to recover from + errors. There's absolutely nothing wrong with a UTF-8 string + containg a LATIN SMALL LETTER E WITH ACUTE, but replacing that + character with "é" will make it more readable to some + people. + """ + return cls.CHARACTER_TO_HTML_ENTITY_RE.sub( + cls._substitute_html_entity, s) + + +class EncodingDetector: + """Suggests a number of possible encodings for a bytestring. + + Order of precedence: + + 1. Encodings you specifically tell EncodingDetector to try first + (the override_encodings argument to the constructor). + + 2. An encoding declared within the bytestring itself, either in an + XML declaration (if the bytestring is to be interpreted as an XML + document), or in a tag (if the bytestring is to be + interpreted as an HTML document.) + + 3. An encoding detected through textual analysis by chardet, + cchardet, or a similar external library. + + 4. UTF-8. + + 5. Windows-1252. + """ + def __init__(self, markup, override_encodings=None, is_html=False, + exclude_encodings=None): + self.override_encodings = override_encodings or [] + exclude_encodings = exclude_encodings or [] + self.exclude_encodings = set([x.lower() for x in exclude_encodings]) + self.chardet_encoding = None + self.is_html = is_html + self.declared_encoding = None + + # First order of business: strip a byte-order mark. + self.markup, self.sniffed_encoding = self.strip_byte_order_mark(markup) + + def _usable(self, encoding, tried): + if encoding is not None: + encoding = encoding.lower() + if encoding in self.exclude_encodings: + return False + if encoding not in tried: + tried.add(encoding) + return True + return False + + @property + def encodings(self): + """Yield a number of encodings that might work for this markup.""" + tried = set() + for e in self.override_encodings: + if self._usable(e, tried): + yield e + + # Did the document originally start with a byte-order mark + # that indicated its encoding? + if self._usable(self.sniffed_encoding, tried): + yield self.sniffed_encoding + + # Look within the document for an XML or HTML encoding + # declaration. + if self.declared_encoding is None: + self.declared_encoding = self.find_declared_encoding( + self.markup, self.is_html) + if self._usable(self.declared_encoding, tried): + yield self.declared_encoding + + # Use third-party character set detection to guess at the + # encoding. + if self.chardet_encoding is None: + self.chardet_encoding = chardet_dammit(self.markup) + if self._usable(self.chardet_encoding, tried): + yield self.chardet_encoding + + # As a last-ditch effort, try utf-8 and windows-1252. + for e in ('utf-8', 'windows-1252'): + if self._usable(e, tried): + yield e + + @classmethod + def strip_byte_order_mark(cls, data): + """If a byte-order mark is present, strip it and return the encoding it implies.""" + encoding = None + if isinstance(data, six.text_type): + # Unicode data cannot have a byte-order mark. + return data, encoding + if (len(data) >= 4) and (data[:2] == b'\xfe\xff') \ + and (data[2:4] != '\x00\x00'): + encoding = 'utf-16be' + data = data[2:] + elif (len(data) >= 4) and (data[:2] == b'\xff\xfe') \ + and (data[2:4] != '\x00\x00'): + encoding = 'utf-16le' + data = data[2:] + elif data[:3] == b'\xef\xbb\xbf': + encoding = 'utf-8' + data = data[3:] + elif data[:4] == b'\x00\x00\xfe\xff': + encoding = 'utf-32be' + data = data[4:] + elif data[:4] == b'\xff\xfe\x00\x00': + encoding = 'utf-32le' + data = data[4:] + return data, encoding + + @classmethod + def find_declared_encoding(cls, markup, is_html=False, search_entire_document=False): + """Given a document, tries to find its declared encoding. + + An XML encoding is declared at the beginning of the document. + + An HTML encoding is declared in a tag, hopefully near the + beginning of the document. + """ + if search_entire_document: + xml_endpos = html_endpos = len(markup) + else: + xml_endpos = 1024 + html_endpos = max(2048, int(len(markup) * 0.05)) + + declared_encoding = None + declared_encoding_match = xml_encoding_re.search(markup, endpos=xml_endpos) + if not declared_encoding_match and is_html: + declared_encoding_match = html_meta_re.search(markup, endpos=html_endpos) + if declared_encoding_match is not None: + declared_encoding = declared_encoding_match.groups()[0].decode( + 'ascii', 'replace') + if declared_encoding: + return declared_encoding.lower() + return None + +class UnicodeDammit: + """A class for detecting the encoding of a *ML document and + converting it to a Unicode string. If the source encoding is + windows-1252, can replace MS smart quotes with their HTML or XML + equivalents.""" + + # This dictionary maps commonly seen values for "charset" in HTML + # meta tags to the corresponding Python codec names. It only covers + # values that aren't in Python's aliases and can't be determined + # by the heuristics in find_codec. + CHARSET_ALIASES = {"macintosh": "mac-roman", + "x-sjis": "shift-jis"} + + ENCODINGS_WITH_SMART_QUOTES = [ + "windows-1252", + "iso-8859-1", + "iso-8859-2", + ] + + def __init__(self, markup, override_encodings=[], + smart_quotes_to=None, is_html=False, exclude_encodings=[]): + self.smart_quotes_to = smart_quotes_to + self.tried_encodings = [] + self.contains_replacement_characters = False + self.is_html = is_html + + self.detector = EncodingDetector( + markup, override_encodings, is_html, exclude_encodings) + + # Short-circuit if the data is in Unicode to begin with. + if isinstance(markup, six.text_type) or markup == '': + self.markup = markup + self.unicode_markup = six.text_type(markup) + self.original_encoding = None + return + + # The encoding detector may have stripped a byte-order mark. + # Use the stripped markup from this point on. + self.markup = self.detector.markup + + u = None + for encoding in self.detector.encodings: + markup = self.detector.markup + u = self._convert_from(encoding) + if u is not None: + break + + if not u: + # None of the encodings worked. As an absolute last resort, + # try them again with character replacement. + + for encoding in self.detector.encodings: + if encoding != "ascii": + u = self._convert_from(encoding, "replace") + if u is not None: + logging.warning( + "Some characters could not be decoded, and were " + "replaced with REPLACEMENT CHARACTER.") + self.contains_replacement_characters = True + break + + # If none of that worked, we could at this point force it to + # ASCII, but that would destroy so much data that I think + # giving up is better. + self.unicode_markup = u + if not u: + self.original_encoding = None + + def _sub_ms_char(self, match): + """Changes a MS smart quote character to an XML or HTML + entity, or an ASCII character.""" + orig = match.group(1) + if self.smart_quotes_to == 'ascii': + sub = self.MS_CHARS_TO_ASCII.get(orig).encode() + else: + sub = self.MS_CHARS.get(orig) + if type(sub) == tuple: + if self.smart_quotes_to == 'xml': + sub = '&#x'.encode() + sub[1].encode() + ';'.encode() + else: + sub = '&'.encode() + sub[0].encode() + ';'.encode() + else: + sub = sub.encode() + return sub + + def _convert_from(self, proposed, errors="strict"): + proposed = self.find_codec(proposed) + if not proposed or (proposed, errors) in self.tried_encodings: + return None + self.tried_encodings.append((proposed, errors)) + markup = self.markup + # Convert smart quotes to HTML if coming from an encoding + # that might have them. + if (self.smart_quotes_to is not None + and proposed in self.ENCODINGS_WITH_SMART_QUOTES): + smart_quotes_re = b"([\x80-\x9f])" + smart_quotes_compiled = re.compile(smart_quotes_re) + markup = smart_quotes_compiled.sub(self._sub_ms_char, markup) + + try: + #print "Trying to convert document to %s (errors=%s)" % ( + # proposed, errors) + u = self._to_unicode(markup, proposed, errors) + self.markup = u + self.original_encoding = proposed + except Exception as e: + #print "That didn't work!" + #print e + return None + #print "Correct encoding: %s" % proposed + return self.markup + + def _to_unicode(self, data, encoding, errors="strict"): + '''Given a string and its encoding, decodes the string into Unicode. + %encoding is a string recognized by encodings.aliases''' + return six.text_type(data, encoding, errors) + + @property + def declared_html_encoding(self): + if not self.is_html: + return None + return self.detector.declared_encoding + + def find_codec(self, charset): + value = (self._codec(self.CHARSET_ALIASES.get(charset, charset)) + or (charset and self._codec(charset.replace("-", ""))) + or (charset and self._codec(charset.replace("-", "_"))) + or (charset and charset.lower()) + or charset + ) + if value: + return value.lower() + return None + + def _codec(self, charset): + if not charset: + return charset + codec = None + try: + codecs.lookup(charset) + codec = charset + except (LookupError, ValueError): + pass + return codec + + + # A partial mapping of ISO-Latin-1 to HTML entities/XML numeric entities. + MS_CHARS = {b'\x80': ('euro', '20AC'), + b'\x81': ' ', + b'\x82': ('sbquo', '201A'), + b'\x83': ('fnof', '192'), + b'\x84': ('bdquo', '201E'), + b'\x85': ('hellip', '2026'), + b'\x86': ('dagger', '2020'), + b'\x87': ('Dagger', '2021'), + b'\x88': ('circ', '2C6'), + b'\x89': ('permil', '2030'), + b'\x8A': ('Scaron', '160'), + b'\x8B': ('lsaquo', '2039'), + b'\x8C': ('OElig', '152'), + b'\x8D': '?', + b'\x8E': ('#x17D', '17D'), + b'\x8F': '?', + b'\x90': '?', + b'\x91': ('lsquo', '2018'), + b'\x92': ('rsquo', '2019'), + b'\x93': ('ldquo', '201C'), + b'\x94': ('rdquo', '201D'), + b'\x95': ('bull', '2022'), + b'\x96': ('ndash', '2013'), + b'\x97': ('mdash', '2014'), + b'\x98': ('tilde', '2DC'), + b'\x99': ('trade', '2122'), + b'\x9a': ('scaron', '161'), + b'\x9b': ('rsaquo', '203A'), + b'\x9c': ('oelig', '153'), + b'\x9d': '?', + b'\x9e': ('#x17E', '17E'), + b'\x9f': ('Yuml', ''),} + + # A parochial partial mapping of ISO-Latin-1 to ASCII. Contains + # horrors like stripping diacritical marks to turn á into a, but also + # contains non-horrors like turning “ into ". + MS_CHARS_TO_ASCII = { + b'\x80' : 'EUR', + b'\x81' : ' ', + b'\x82' : ',', + b'\x83' : 'f', + b'\x84' : ',,', + b'\x85' : '...', + b'\x86' : '+', + b'\x87' : '++', + b'\x88' : '^', + b'\x89' : '%', + b'\x8a' : 'S', + b'\x8b' : '<', + b'\x8c' : 'OE', + b'\x8d' : '?', + b'\x8e' : 'Z', + b'\x8f' : '?', + b'\x90' : '?', + b'\x91' : "'", + b'\x92' : "'", + b'\x93' : '"', + b'\x94' : '"', + b'\x95' : '*', + b'\x96' : '-', + b'\x97' : '--', + b'\x98' : '~', + b'\x99' : '(TM)', + b'\x9a' : 's', + b'\x9b' : '>', + b'\x9c' : 'oe', + b'\x9d' : '?', + b'\x9e' : 'z', + b'\x9f' : 'Y', + b'\xa0' : ' ', + b'\xa1' : '!', + b'\xa2' : 'c', + b'\xa3' : 'GBP', + b'\xa4' : '$', #This approximation is especially parochial--this is the + #generic currency symbol. + b'\xa5' : 'YEN', + b'\xa6' : '|', + b'\xa7' : 'S', + b'\xa8' : '..', + b'\xa9' : '', + b'\xaa' : '(th)', + b'\xab' : '<<', + b'\xac' : '!', + b'\xad' : ' ', + b'\xae' : '(R)', + b'\xaf' : '-', + b'\xb0' : 'o', + b'\xb1' : '+-', + b'\xb2' : '2', + b'\xb3' : '3', + b'\xb4' : ("'", 'acute'), + b'\xb5' : 'u', + b'\xb6' : 'P', + b'\xb7' : '*', + b'\xb8' : ',', + b'\xb9' : '1', + b'\xba' : '(th)', + b'\xbb' : '>>', + b'\xbc' : '1/4', + b'\xbd' : '1/2', + b'\xbe' : '3/4', + b'\xbf' : '?', + b'\xc0' : 'A', + b'\xc1' : 'A', + b'\xc2' : 'A', + b'\xc3' : 'A', + b'\xc4' : 'A', + b'\xc5' : 'A', + b'\xc6' : 'AE', + b'\xc7' : 'C', + b'\xc8' : 'E', + b'\xc9' : 'E', + b'\xca' : 'E', + b'\xcb' : 'E', + b'\xcc' : 'I', + b'\xcd' : 'I', + b'\xce' : 'I', + b'\xcf' : 'I', + b'\xd0' : 'D', + b'\xd1' : 'N', + b'\xd2' : 'O', + b'\xd3' : 'O', + b'\xd4' : 'O', + b'\xd5' : 'O', + b'\xd6' : 'O', + b'\xd7' : '*', + b'\xd8' : 'O', + b'\xd9' : 'U', + b'\xda' : 'U', + b'\xdb' : 'U', + b'\xdc' : 'U', + b'\xdd' : 'Y', + b'\xde' : 'b', + b'\xdf' : 'B', + b'\xe0' : 'a', + b'\xe1' : 'a', + b'\xe2' : 'a', + b'\xe3' : 'a', + b'\xe4' : 'a', + b'\xe5' : 'a', + b'\xe6' : 'ae', + b'\xe7' : 'c', + b'\xe8' : 'e', + b'\xe9' : 'e', + b'\xea' : 'e', + b'\xeb' : 'e', + b'\xec' : 'i', + b'\xed' : 'i', + b'\xee' : 'i', + b'\xef' : 'i', + b'\xf0' : 'o', + b'\xf1' : 'n', + b'\xf2' : 'o', + b'\xf3' : 'o', + b'\xf4' : 'o', + b'\xf5' : 'o', + b'\xf6' : 'o', + b'\xf7' : '/', + b'\xf8' : 'o', + b'\xf9' : 'u', + b'\xfa' : 'u', + b'\xfb' : 'u', + b'\xfc' : 'u', + b'\xfd' : 'y', + b'\xfe' : 'b', + b'\xff' : 'y', + } + + # A map used when removing rogue Windows-1252/ISO-8859-1 + # characters in otherwise UTF-8 documents. + # + # Note that \x81, \x8d, \x8f, \x90, and \x9d are undefined in + # Windows-1252. + WINDOWS_1252_TO_UTF8 = { + 0x80 : b'\xe2\x82\xac', # € + 0x82 : b'\xe2\x80\x9a', # ‚ + 0x83 : b'\xc6\x92', # ƒ + 0x84 : b'\xe2\x80\x9e', # „ + 0x85 : b'\xe2\x80\xa6', # … + 0x86 : b'\xe2\x80\xa0', # † + 0x87 : b'\xe2\x80\xa1', # ‡ + 0x88 : b'\xcb\x86', # ˆ + 0x89 : b'\xe2\x80\xb0', # ‰ + 0x8a : b'\xc5\xa0', # Š + 0x8b : b'\xe2\x80\xb9', # ‹ + 0x8c : b'\xc5\x92', # Œ + 0x8e : b'\xc5\xbd', # Ž + 0x91 : b'\xe2\x80\x98', # ‘ + 0x92 : b'\xe2\x80\x99', # ’ + 0x93 : b'\xe2\x80\x9c', # “ + 0x94 : b'\xe2\x80\x9d', # ” + 0x95 : b'\xe2\x80\xa2', # • + 0x96 : b'\xe2\x80\x93', # – + 0x97 : b'\xe2\x80\x94', # — + 0x98 : b'\xcb\x9c', # ˜ + 0x99 : b'\xe2\x84\xa2', # ™ + 0x9a : b'\xc5\xa1', # š + 0x9b : b'\xe2\x80\xba', # › + 0x9c : b'\xc5\x93', # œ + 0x9e : b'\xc5\xbe', # ž + 0x9f : b'\xc5\xb8', # Ÿ + 0xa0 : b'\xc2\xa0', #   + 0xa1 : b'\xc2\xa1', # ¡ + 0xa2 : b'\xc2\xa2', # ¢ + 0xa3 : b'\xc2\xa3', # £ + 0xa4 : b'\xc2\xa4', # ¤ + 0xa5 : b'\xc2\xa5', # ¥ + 0xa6 : b'\xc2\xa6', # ¦ + 0xa7 : b'\xc2\xa7', # § + 0xa8 : b'\xc2\xa8', # ¨ + 0xa9 : b'\xc2\xa9', # © + 0xaa : b'\xc2\xaa', # ª + 0xab : b'\xc2\xab', # « + 0xac : b'\xc2\xac', # ¬ + 0xad : b'\xc2\xad', # ­ + 0xae : b'\xc2\xae', # ® + 0xaf : b'\xc2\xaf', # ¯ + 0xb0 : b'\xc2\xb0', # ° + 0xb1 : b'\xc2\xb1', # ± + 0xb2 : b'\xc2\xb2', # ² + 0xb3 : b'\xc2\xb3', # ³ + 0xb4 : b'\xc2\xb4', # ´ + 0xb5 : b'\xc2\xb5', # µ + 0xb6 : b'\xc2\xb6', # ¶ + 0xb7 : b'\xc2\xb7', # · + 0xb8 : b'\xc2\xb8', # ¸ + 0xb9 : b'\xc2\xb9', # ¹ + 0xba : b'\xc2\xba', # º + 0xbb : b'\xc2\xbb', # » + 0xbc : b'\xc2\xbc', # ¼ + 0xbd : b'\xc2\xbd', # ½ + 0xbe : b'\xc2\xbe', # ¾ + 0xbf : b'\xc2\xbf', # ¿ + 0xc0 : b'\xc3\x80', # À + 0xc1 : b'\xc3\x81', # Á + 0xc2 : b'\xc3\x82', #  + 0xc3 : b'\xc3\x83', # à + 0xc4 : b'\xc3\x84', # Ä + 0xc5 : b'\xc3\x85', # Å + 0xc6 : b'\xc3\x86', # Æ + 0xc7 : b'\xc3\x87', # Ç + 0xc8 : b'\xc3\x88', # È + 0xc9 : b'\xc3\x89', # É + 0xca : b'\xc3\x8a', # Ê + 0xcb : b'\xc3\x8b', # Ë + 0xcc : b'\xc3\x8c', # Ì + 0xcd : b'\xc3\x8d', # Í + 0xce : b'\xc3\x8e', # Î + 0xcf : b'\xc3\x8f', # Ï + 0xd0 : b'\xc3\x90', # Ð + 0xd1 : b'\xc3\x91', # Ñ + 0xd2 : b'\xc3\x92', # Ò + 0xd3 : b'\xc3\x93', # Ó + 0xd4 : b'\xc3\x94', # Ô + 0xd5 : b'\xc3\x95', # Õ + 0xd6 : b'\xc3\x96', # Ö + 0xd7 : b'\xc3\x97', # × + 0xd8 : b'\xc3\x98', # Ø + 0xd9 : b'\xc3\x99', # Ù + 0xda : b'\xc3\x9a', # Ú + 0xdb : b'\xc3\x9b', # Û + 0xdc : b'\xc3\x9c', # Ü + 0xdd : b'\xc3\x9d', # Ý + 0xde : b'\xc3\x9e', # Þ + 0xdf : b'\xc3\x9f', # ß + 0xe0 : b'\xc3\xa0', # à + 0xe1 : b'\xa1', # á + 0xe2 : b'\xc3\xa2', # â + 0xe3 : b'\xc3\xa3', # ã + 0xe4 : b'\xc3\xa4', # ä + 0xe5 : b'\xc3\xa5', # å + 0xe6 : b'\xc3\xa6', # æ + 0xe7 : b'\xc3\xa7', # ç + 0xe8 : b'\xc3\xa8', # è + 0xe9 : b'\xc3\xa9', # é + 0xea : b'\xc3\xaa', # ê + 0xeb : b'\xc3\xab', # ë + 0xec : b'\xc3\xac', # ì + 0xed : b'\xc3\xad', # í + 0xee : b'\xc3\xae', # î + 0xef : b'\xc3\xaf', # ï + 0xf0 : b'\xc3\xb0', # ð + 0xf1 : b'\xc3\xb1', # ñ + 0xf2 : b'\xc3\xb2', # ò + 0xf3 : b'\xc3\xb3', # ó + 0xf4 : b'\xc3\xb4', # ô + 0xf5 : b'\xc3\xb5', # õ + 0xf6 : b'\xc3\xb6', # ö + 0xf7 : b'\xc3\xb7', # ÷ + 0xf8 : b'\xc3\xb8', # ø + 0xf9 : b'\xc3\xb9', # ù + 0xfa : b'\xc3\xba', # ú + 0xfb : b'\xc3\xbb', # û + 0xfc : b'\xc3\xbc', # ü + 0xfd : b'\xc3\xbd', # ý + 0xfe : b'\xc3\xbe', # þ + } + + MULTIBYTE_MARKERS_AND_SIZES = [ + (0xc2, 0xdf, 2), # 2-byte characters start with a byte C2-DF + (0xe0, 0xef, 3), # 3-byte characters start with E0-EF + (0xf0, 0xf4, 4), # 4-byte characters start with F0-F4 + ] + + FIRST_MULTIBYTE_MARKER = MULTIBYTE_MARKERS_AND_SIZES[0][0] + LAST_MULTIBYTE_MARKER = MULTIBYTE_MARKERS_AND_SIZES[-1][1] + + @classmethod + def detwingle(cls, in_bytes, main_encoding="utf8", + embedded_encoding="windows-1252"): + """Fix characters from one encoding embedded in some other encoding. + + Currently the only situation supported is Windows-1252 (or its + subset ISO-8859-1), embedded in UTF-8. + + The input must be a bytestring. If you've already converted + the document to Unicode, you're too late. + + The output is a bytestring in which `embedded_encoding` + characters have been converted to their `main_encoding` + equivalents. + """ + if embedded_encoding.replace('_', '-').lower() not in ( + 'windows-1252', 'windows_1252'): + raise NotImplementedError( + "Windows-1252 and ISO-8859-1 are the only currently supported " + "embedded encodings.") + + if main_encoding.lower() not in ('utf8', 'utf-8'): + raise NotImplementedError( + "UTF-8 is the only currently supported main encoding.") + + byte_chunks = [] + + chunk_start = 0 + pos = 0 + while pos < len(in_bytes): + byte = in_bytes[pos] + if not isinstance(byte, int): + # Python 2.x + byte = ord(byte) + if (byte >= cls.FIRST_MULTIBYTE_MARKER + and byte <= cls.LAST_MULTIBYTE_MARKER): + # This is the start of a UTF-8 multibyte character. Skip + # to the end. + for start, end, size in cls.MULTIBYTE_MARKERS_AND_SIZES: + if byte >= start and byte <= end: + pos += size + break + elif byte >= 0x80 and byte in cls.WINDOWS_1252_TO_UTF8: + # We found a Windows-1252 character! + # Save the string up to this point as a chunk. + byte_chunks.append(in_bytes[chunk_start:pos]) + + # Now translate the Windows-1252 character into UTF-8 + # and add it as another, one-byte chunk. + byte_chunks.append(cls.WINDOWS_1252_TO_UTF8[byte]) + pos += 1 + chunk_start = pos + else: + # Go on to the next character. + pos += 1 + if chunk_start == 0: + # The string is unchanged. + return in_bytes + else: + # Store the final chunk. + byte_chunks.append(in_bytes[chunk_start:]) + return b''.join(byte_chunks) + diff --git a/script.module.slyguy/resources/modules/bs4/diagnose.py b/script.module.slyguy/resources/modules/bs4/diagnose.py new file mode 100644 index 00000000..aba97f22 --- /dev/null +++ b/script.module.slyguy/resources/modules/bs4/diagnose.py @@ -0,0 +1,216 @@ +"""Diagnostic functions, mainly for use when doing tech support.""" + +__license__ = "MIT" + +import cProfile +from StringIO import StringIO +from HTMLParser import HTMLParser +import bs4 +from bs4 import BeautifulSoup, __version__ +from bs4.builder import builder_registry + +import os +import pstats +import random +import tempfile +import time +import traceback +import sys +import cProfile + +def diagnose(data): + """Diagnostic suite for isolating common problems.""" + print("Diagnostic running on Beautiful Soup %s" % __version__) + print("Python version %s" % sys.version) + + basic_parsers = ["html.parser", "html5lib", "lxml"] + for name in basic_parsers: + for builder in builder_registry.builders: + if name in builder.features: + break + else: + basic_parsers.remove(name) + print( + "I noticed that %s is not installed. Installing it may help." % + name) + + if 'lxml' in basic_parsers: + basic_parsers.append(["lxml", "xml"]) + try: + from lxml import etree + print("Found lxml version %s" % ".".join(map(str,etree.LXML_VERSION))) + except ImportError, e: + print( + "lxml is not installed or couldn't be imported.") + + + if 'html5lib' in basic_parsers: + try: + import html5lib + print("Found html5lib version %s" % html5lib.__version__) + except ImportError, e: + print( + "html5lib is not installed or couldn't be imported.") + + if hasattr(data, 'read'): + data = data.read() + elif os.path.exists(data): + print('"%s" looks like a filename. Reading data from the file.' % data) + data = open(data).read() + elif data.startswith("http:") or data.startswith("https:"): + print('"%s" looks like a URL. Beautiful Soup is not an HTTP client.' % data) + print("You need to use some other library to get the document behind the URL, and feed that document to Beautiful Soup.") + return + print + + for parser in basic_parsers: + print("Trying to parse your markup with %s" % parser) + success = False + try: + soup = BeautifulSoup(data, parser) + success = True + except Exception, e: + print("%s could not parse the markup." % parser) + traceback.print_exc() + if success: + print("Here's what %s did with the markup:" % parser) + print soup.prettify() + + print "-" * 80 + +def lxml_trace(data, html=True, **kwargs): + """Print out the lxml events that occur during parsing. + + This lets you see how lxml parses a document when no Beautiful + Soup code is running. + """ + from lxml import etree + for event, element in etree.iterparse(StringIO(data), html=html, **kwargs): + print("%s, %4s, %s" % (event, element.tag, element.text)) + +class AnnouncingParser(HTMLParser): + """Announces HTMLParser parse events, without doing anything else.""" + + def _p(self, s): + print(s) + + def handle_starttag(self, name, attrs): + self._p("%s START" % name) + + def handle_endtag(self, name): + self._p("%s END" % name) + + def handle_data(self, data): + self._p("%s DATA" % data) + + def handle_charref(self, name): + self._p("%s CHARREF" % name) + + def handle_entityref(self, name): + self._p("%s ENTITYREF" % name) + + def handle_comment(self, data): + self._p("%s COMMENT" % data) + + def handle_decl(self, data): + self._p("%s DECL" % data) + + def unknown_decl(self, data): + self._p("%s UNKNOWN-DECL" % data) + + def handle_pi(self, data): + self._p("%s PI" % data) + +def htmlparser_trace(data): + """Print out the HTMLParser events that occur during parsing. + + This lets you see how HTMLParser parses a document when no + Beautiful Soup code is running. + """ + parser = AnnouncingParser() + parser.feed(data) + +_vowels = "aeiou" +_consonants = "bcdfghjklmnpqrstvwxyz" + +def rword(length=5): + "Generate a random word-like string." + s = '' + for i in range(length): + if i % 2 == 0: + t = _consonants + else: + t = _vowels + s += random.choice(t) + return s + +def rsentence(length=4): + "Generate a random sentence-like string." + return " ".join(rword(random.randint(4,9)) for i in range(length)) + +def rdoc(num_elements=1000): + """Randomly generate an invalid HTML document.""" + tag_names = ['p', 'div', 'span', 'i', 'b', 'script', 'table'] + elements = [] + for i in range(num_elements): + choice = random.randint(0,3) + if choice == 0: + # New tag. + tag_name = random.choice(tag_names) + elements.append("<%s>" % tag_name) + elif choice == 1: + elements.append(rsentence(random.randint(1,4))) + elif choice == 2: + # Close a tag. + tag_name = random.choice(tag_names) + elements.append("" % tag_name) + return "" + "\n".join(elements) + "" + +def benchmark_parsers(num_elements=100000): + """Very basic head-to-head performance benchmark.""" + print "Comparative parser benchmark on Beautiful Soup %s" % __version__ + data = rdoc(num_elements) + print "Generated a large invalid HTML document (%d bytes)." % len(data) + + for parser in ["lxml", ["lxml", "html"], "html5lib", "html.parser"]: + success = False + try: + a = time.time() + soup = BeautifulSoup(data, parser) + b = time.time() + success = True + except Exception, e: + print "%s could not parse the markup." % parser + traceback.print_exc() + if success: + print "BS4+%s parsed the markup in %.2fs." % (parser, b-a) + + from lxml import etree + a = time.time() + etree.HTML(data) + b = time.time() + print "Raw lxml parsed the markup in %.2fs." % (b-a) + + import html5lib + parser = html5lib.HTMLParser() + a = time.time() + parser.parse(data) + b = time.time() + print "Raw html5lib parsed the markup in %.2fs." % (b-a) + +def profile(num_elements=100000, parser="lxml"): + + filehandle = tempfile.NamedTemporaryFile() + filename = filehandle.name + + data = rdoc(num_elements) + vars = dict(bs4=bs4, data=data, parser=parser) + cProfile.runctx('bs4.BeautifulSoup(data, parser)' , vars, vars, filename) + + stats = pstats.Stats(filename) + # stats.strip_dirs() + stats.sort_stats("cumulative") + stats.print_stats('_html5lib|bs4', 50) + +if __name__ == '__main__': + diagnose(sys.stdin.read()) diff --git a/script.module.slyguy/resources/modules/bs4/element.py b/script.module.slyguy/resources/modules/bs4/element.py new file mode 100644 index 00000000..69d3c8ab --- /dev/null +++ b/script.module.slyguy/resources/modules/bs4/element.py @@ -0,0 +1,1730 @@ +__license__ = "MIT" + +from pdb import set_trace +import collections +import re +import sys +import six +import warnings +from bs4.dammit import EntitySubstitution + +DEFAULT_OUTPUT_ENCODING = "utf-8" +PY3K = (sys.version_info[0] > 2) + +whitespace_re = re.compile("\s+") + +def _alias(attr): + """Alias one attribute name to another for backward compatibility""" + @property + def alias(self): + return getattr(self, attr) + + @alias.setter + def alias(self): + return setattr(self, attr) + return alias + + +class NamespacedAttribute(six.text_type): + + def __new__(cls, prefix, name, namespace=None): + if name is None: + obj = six.text_type.__new__(cls, prefix) + elif prefix is None: + # Not really namespaced. + obj = six.text_type.__new__(cls, name) + else: + obj = six.text_type.__new__(cls, prefix + ":" + name) + obj.prefix = prefix + obj.name = name + obj.namespace = namespace + return obj + +class AttributeValueWithCharsetSubstitution(six.text_type): + """A stand-in object for a character encoding specified in HTML.""" + +class CharsetMetaAttributeValue(AttributeValueWithCharsetSubstitution): + """A generic stand-in for the value of a meta tag's 'charset' attribute. + + When Beautiful Soup parses the markup '', the + value of the 'charset' attribute will be one of these objects. + """ + + def __new__(cls, original_value): + obj = six.text_type.__new__(cls, original_value) + obj.original_value = original_value + return obj + + def encode(self, encoding): + return encoding + + +class ContentMetaAttributeValue(AttributeValueWithCharsetSubstitution): + """A generic stand-in for the value of a meta tag's 'content' attribute. + + When Beautiful Soup parses the markup: + + + The value of the 'content' attribute will be one of these objects. + """ + + CHARSET_RE = re.compile("((^|;)\s*charset=)([^;]*)", re.M) + + def __new__(cls, original_value): + match = cls.CHARSET_RE.search(original_value) + if match is None: + # No substitution necessary. + return six.text_type.__new__(six.text_type, original_value) + + obj = six.text_type.__new__(cls, original_value) + obj.original_value = original_value + return obj + + def encode(self, encoding): + def rewrite(match): + return match.group(1) + encoding + return self.CHARSET_RE.sub(rewrite, self.original_value) + +class HTMLAwareEntitySubstitution(EntitySubstitution): + + """Entity substitution rules that are aware of some HTML quirks. + + Specifically, the contents of