├── docs ├── api │ ├── utils.rst │ ├── index.rst │ ├── resolver.rst │ ├── storage.rst │ ├── script.rst │ ├── listitem.rst │ └── route.rst ├── examples.rst ├── Makefile ├── index.rst ├── conf.py └── tutorial.rst ├── script.module.codequick ├── resources │ ├── icon.png │ ├── media │ │ ├── next.png │ │ ├── playlist.png │ │ ├── recent.png │ │ ├── search.png │ │ ├── videos.png │ │ ├── search_new.png │ │ └── licensing.txt │ └── language │ │ ├── resource.language.he_il │ │ └── strings.po │ │ ├── resource.language.en_gb │ │ └── strings.po │ │ └── resource.language.fr_fr │ │ └── strings.po ├── lib │ └── codequick │ │ ├── localized.py │ │ ├── __init__.py │ │ ├── search.py │ │ ├── utils.py │ │ ├── route.py │ │ ├── storage.py │ │ ├── resolver.py │ │ ├── support.py │ │ └── script.py └── addon.xml ├── requirements.txt ├── .coveragerc ├── tests ├── __init__.py ├── YDStreamExtractor.py ├── test_utils.py ├── test_youtube.py ├── test_storage.py ├── test_script.py ├── test_resolver.py ├── test_route.py ├── test_search.py └── test_support.py ├── .github ├── dependabot.yml └── workflows │ └── kodi-submitter.yml ├── .travis.yml ├── .gitignore ├── README.rst ├── HISTORY.md └── LICENSE /docs/api/utils.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ===== 3 | A collection of useful functions. 4 | 5 | .. automodule:: codequick.utils 6 | :members: 7 | -------------------------------------------------------------------------------- /script.module.codequick/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willforde/script.module.codequick/HEAD/script.module.codequick/resources/icon.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | https://github.com/willforde/kodi-addondev/archive/master.zip#addondev 2 | https://github.com/willforde/kodi-mock/archive/master.zip#kodimock 3 | -------------------------------------------------------------------------------- /script.module.codequick/resources/media/next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willforde/script.module.codequick/HEAD/script.module.codequick/resources/media/next.png -------------------------------------------------------------------------------- /script.module.codequick/resources/media/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willforde/script.module.codequick/HEAD/script.module.codequick/resources/media/playlist.png -------------------------------------------------------------------------------- /script.module.codequick/resources/media/recent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willforde/script.module.codequick/HEAD/script.module.codequick/resources/media/recent.png -------------------------------------------------------------------------------- /script.module.codequick/resources/media/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willforde/script.module.codequick/HEAD/script.module.codequick/resources/media/search.png -------------------------------------------------------------------------------- /script.module.codequick/resources/media/videos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willforde/script.module.codequick/HEAD/script.module.codequick/resources/media/videos.png -------------------------------------------------------------------------------- /script.module.codequick/resources/media/search_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willforde/script.module.codequick/HEAD/script.module.codequick/resources/media/search_new.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=script.module.codequick/lib/codequick 3 | branch=True 4 | 5 | [report] 6 | exclude_lines = 7 | def __str__ 8 | def __repr__ 9 | pragma: no cover 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from addondev import initializer 2 | import os 3 | 4 | # Initialize mock kodi environment 5 | initializer(os.path.join(os.path.dirname(os.path.dirname(__file__)), "script.module.codequick")) 6 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | Table of Contents. 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | 9 | script 10 | route 11 | resolver 12 | listitem 13 | storage 14 | utils 15 | -------------------------------------------------------------------------------- /docs/api/resolver.rst: -------------------------------------------------------------------------------- 1 | Resolver 2 | ======== 3 | This module is used for the creation of “Route callbacks”. 4 | 5 | .. autoclass:: codequick.resolver.Resolver 6 | :members: 7 | :exclude-members: create_loopback 8 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Examples 3 | ======== 4 | There are no better examples than actual add-on's that use the codequick framework. 5 | So here is a list of add-ons that use this framework. 6 | 7 | * https://github.com/willforde/plugin.video.watchmojo 8 | * https://github.com/willforde/plugin.video.earthtouch 9 | * https://github.com/willforde/plugin.video.metalvideo 10 | * https://github.com/willforde/plugin.video.science.friday 11 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/localized.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants maping strings to localized identifier. 3 | """ 4 | 5 | # KODI Strings 6 | PLAYLISTS = 136 7 | SEARCH = 137 8 | REMOVE = 1210 9 | ENTER_SEARCH_STRING = 16017 10 | SELECT_PLAYBACK_ITEM = 25006 11 | 12 | # Codequick Strings 13 | RELATED_VIDEOS = 32201 14 | RECENT_VIDEOS = 32002 15 | ALLVIDEOS = 32003 16 | RECENT_VIDEOS_PLOT = 32004 17 | NEXT_PAGE_PLOT = 32005 18 | SEARCH_PLOT = 32006 19 | PLAYLISTS_PLOT = 32007 20 | NO_VIDEO = 32401 21 | 22 | # Common Kodi Strings 23 | NO_DATA = 33077 24 | NEXT_PAGE = 33078 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for GitHub Actions 9 | - package-ecosystem: "github-actions" 10 | target-branch: "main" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /docs/api/storage.rst: -------------------------------------------------------------------------------- 1 | Storage 2 | ======= 3 | Persistent data storage objects. These objects will act like normal 4 | built-in data types, except all data will be saved to disk for later access when flushed. 5 | 6 | .. autoclass:: codequick.storage.PersistentDict 7 | :members: 8 | 9 | .. automethod:: codequick.storage.PersistentDict.flush 10 | .. automethod:: codequick.storage.PersistentDict.close 11 | 12 | 13 | .. autoclass:: codequick.storage.PersistentList 14 | :members: 15 | 16 | .. automethod:: codequick.storage.PersistentList.flush 17 | .. automethod:: codequick.storage.PersistentList.close 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | arch: amd64 3 | os: linux 4 | dist: bionic 5 | 6 | git: 7 | depth: 3 8 | quiet: true 9 | autocrlf: input 10 | 11 | python: 12 | - "2.7" 13 | - "3.6" 14 | 15 | cache: 16 | directories: 17 | - $HOME/.cache/kodi_mock/addons 18 | 19 | before_cache: 20 | - rm -rf $HOME/.cache/kodi_mock/addons/packages 21 | 22 | install: 23 | - pip install coveralls 24 | - pip install pytest-cov 25 | - pip install https://github.com/willforde/kodi-addondev/archive/master.zip 26 | - pip install https://github.com/willforde/kodi-mock/archive/master.zip 27 | 28 | script: pytest --cov 29 | after_success: coveralls 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = CodeQuick 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api/script.rst: -------------------------------------------------------------------------------- 1 | Script 2 | ====== 3 | This module is used for creating "Script" callback's, which are also used as the base for all other types of callbacks. 4 | 5 | .. autofunction:: codequick.run 6 | 7 | .. autoclass:: codequick.script.Script 8 | :members: 9 | 10 | .. attribute:: handle 11 | :annotation: = -1 12 | 13 | The Kodi handle that this add-on was started with. 14 | 15 | .. autoclass:: codequick.script.Settings 16 | 17 | .. automethod:: __getitem__ 18 | .. automethod:: __setitem__ 19 | .. automethod:: get_string(key, addon_id=None) 20 | .. automethod:: get_boolean(key, addon_id=None) 21 | .. automethod:: get_int(key, addon_id=None) 22 | .. automethod:: get_number(key, addon_id=None) 23 | -------------------------------------------------------------------------------- /docs/api/listitem.rst: -------------------------------------------------------------------------------- 1 | Listitem 2 | ======== 3 | The "listitem" control is used for the creating of item lists in Kodi. 4 | 5 | .. autoclass:: codequick.listing.Listitem 6 | :members: 7 | :exclude-members: info, art, stream, context, params, property, subtitles 8 | 9 | .. autoinstanceattribute:: subtitles 10 | :annotation: = list() 11 | 12 | .. autoinstanceattribute:: art 13 | :annotation: = Art() 14 | 15 | .. autoinstanceattribute:: info 16 | :annotation: = Info() 17 | 18 | .. autoinstanceattribute:: stream 19 | :annotation: = Stream() 20 | 21 | .. autoinstanceattribute:: context 22 | :annotation: = Context() 23 | 24 | .. autoinstanceattribute:: property 25 | :annotation: = dict() 26 | 27 | .. autoinstanceattribute:: params 28 | :annotation: = dict() 29 | 30 | .. autoclass:: codequick.listing.Art 31 | :members: 32 | 33 | .. autoclass:: codequick.listing.Info 34 | :members: 35 | 36 | .. autoclass:: codequick.listing.Stream 37 | :members: 38 | 39 | .. autoclass:: codequick.listing.Context 40 | :members: 41 | -------------------------------------------------------------------------------- /script.module.codequick/resources/media/licensing.txt: -------------------------------------------------------------------------------- 1 | next.png 2 | Author: Google 3 | License: Creative Commons (Attribution-Share Alike 3.0 Unported) 4 | url: https://www.iconfinder.com/icons/352561/navigate_next_icon#size=256 5 | 6 | playlist.png 7 | Author: Google 8 | License: Creative Commons (Attribution-Share Alike 3.0 Unported) 9 | url: https://www.iconfinder.com/icons/352490/list_icon#size=256 10 | 11 | recent.png 12 | Author: Google 13 | License: Creative Commons (Attribution-Share Alike 3.0 Unported) 14 | url: https://www.iconfinder.com/icons/352085/schedule_icon#size=256 15 | 16 | videos.png 17 | Author: Google 18 | License: Creative Commons (Attribution-Share Alike 3.0 Unported) 19 | url: https://www.iconfinder.com/icons/352073/circle_fill_play_icon#size=256 20 | 21 | search.png 22 | Author: Xinh Studio 23 | License: Free for commercial use 24 | url: https://www.iconfinder.com/icons/763314/add_editor_in_magnifier_magnifying_plus_search_view_zoom_icon#size=256 25 | 26 | search_new.png 27 | Author: Xinh Studio 28 | License: Free for commercial use 29 | url: https://www.iconfinder.com/icons/763314/add_editor_in_magnifier_magnifying_plus_search_view_zoom_icon#size=256 30 | -------------------------------------------------------------------------------- /script.module.codequick/addon.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Framework for creating kodi add-on's. 12 | Codequick is a framework for kodi add-on's. The goal of this framework is to simplify add-on development. This is achieved by reducing the amount of boilerplate code to a minimum, automating tasks like route dispatching and sort method selection. Ultimately allowing the developer to focus primarily on scraping content from websites and passing it to kodi. 13 | GPL-2.0-only 14 | https://github.com/willforde/script.module.codequick 15 | willforde+codequick@gmail.com 16 | all 17 | 18 | resources/icon.png 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/YDStreamExtractor.py: -------------------------------------------------------------------------------- 1 | """ 2 | mode 0 = single url 3 | mode 1 = multiple urls 4 | mode 2 = no video 5 | mode 3 = raises error 6 | mode 4 = warning message 7 | """ 8 | import time 9 | mode = 0 10 | callback_errors = object 11 | 12 | 13 | class VideoInfo(object): 14 | def __init__(self): 15 | self.title = '' 16 | self.description = '' 17 | self.thumbnail = '' 18 | self.webpage = '' 19 | self._streams = None 20 | self.sourceName = '' 21 | self.info = None 22 | self._selection = None 23 | self.downloadID = str(time.time()) 24 | 25 | def hasMultipleStreams(self): 26 | return mode == 1 27 | 28 | def streamURL(self): 29 | return "video.mkv" 30 | 31 | def streams(self): 32 | class Extractor(object): 33 | def title(self): 34 | return "Youtube" 35 | 36 | return [{"ytdl_format": {"extractor": Extractor()}, "title": "Video title"}] 37 | 38 | def selectStream(self, idx): 39 | pass 40 | 41 | def __nonzero__(self): # py2 42 | return mode != 2 43 | 44 | def __bool__(self): # py3 45 | return mode != 2 46 | 47 | 48 | def getVideoInfo(url, quality=None, resolve_redirects=False): 49 | if mode == 3: 50 | callback_errors("ERROR: I was told to raise an error") 51 | else: 52 | if mode == 4: 53 | callback_errors("WARNING: I was told to raise a warning message") 54 | return VideoInfo() 55 | 56 | 57 | def setOutputCallback(callback): 58 | global callback_errors 59 | callback_errors = callback 60 | 61 | 62 | def overrideParam(key, val): 63 | pass 64 | -------------------------------------------------------------------------------- /.github/workflows/kodi-submitter.yml: -------------------------------------------------------------------------------- 1 | name: Kodi Addon-Submitter 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | kodi-addon-submitter: 10 | runs-on: ubuntu-latest 11 | name: Kodi addon submitter 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Generate distribution zip and submit to official kodi repository 17 | id: kodi-addon-submitter 18 | uses: xbmc/action-kodi-addon-submitter@v1.3 19 | with: 20 | kodi-repository: repo-scripts 21 | kodi-version: krypton 22 | addon-id: script.module.codequick 23 | kodi-matrix: true 24 | sub-directory: true 25 | env: 26 | GH_USERNAME: ${{ github.actor }} 27 | GH_TOKEN: ${{secrets.GH_TOKEN}} 28 | EMAIL: ${{secrets.EMAIL}} 29 | 30 | - name: Create Github Release 31 | id: create_release 32 | uses: actions/create-release@v1 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | with: 36 | tag_name: ${{ github.ref }} 37 | release_name: Release ${{ github.ref }} 38 | draft: false 39 | prerelease: false 40 | 41 | - name: Upload krypton Addon zip to github release 42 | id: upload-release-asset 43 | uses: actions/upload-release-asset@v1 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | with: 47 | upload_url: ${{ steps.create_release.outputs.upload_url }} 48 | asset_path: ${{ steps.kodi-addon-submitter.outputs.addon-zip }} 49 | asset_name: ${{ steps.kodi-addon-submitter.outputs.addon-zip }} 50 | asset_content_type: application/zip 51 | -------------------------------------------------------------------------------- /docs/api/route.rst: -------------------------------------------------------------------------------- 1 | Route 2 | ===== 3 | This module is used for the creation of “Route callbacks”. 4 | 5 | .. autoclass:: codequick.route.Route 6 | :members: add_sort_methods 7 | 8 | .. attribute:: autosort 9 | :annotation: = True 10 | 11 | Set to ``False`` to disable auto sortmethod selection. 12 | 13 | .. note:: If autosort is disabled and no sortmethods are given, then SORT_METHOD_UNSORTED will be set. 14 | 15 | .. attribute:: category 16 | 17 | Manualy specifiy the category for the current folder view. 18 | Equivalent to setting ``xbmcplugin.setPluginCategory()`` 19 | 20 | .. attribute:: redirect_single_item 21 | :annotation: = False 22 | 23 | When this attribute is set to ``True`` and there is only one folder listitem available in the folder view, 24 | then that listitem will be automaticly called for you. 25 | 26 | .. attribute:: update_listing 27 | :annotation: = False 28 | 29 | When set to ``True``, the current page of listitems will be updated, instead of creating a new page of listitems. 30 | 31 | .. attribute:: content_type 32 | :annotation: = None 33 | 34 | The add-on’s "content type". 35 | 36 | If not given, then the "content type" is based on the "mediatype" infolabel of the listitems. 37 | If the “mediatype” infolabel” was not set, then it defaults to “files/videos”, based on type of content. 38 | 39 | * "files" when listing folders. 40 | * "videos" when listing videos. 41 | 42 | .. seealso:: The full list of "content types" can be found at: 43 | 44 | https://codedocs.xyz/xbmc/xbmc/group__python__xbmcplugin.html#gaa30572d1e5d9d589e1cd3bfc1e2318d6 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | .idea/ 97 | docs/_static/ 98 | .pytest_cache/ 99 | 100 | *.zip 101 | -------------------------------------------------------------------------------- /script.module.codequick/resources/language/resource.language.he_il/strings.po: -------------------------------------------------------------------------------- 1 | # Kodi Media Center language file 2 | # Addon Name: CodeQuick 3 | # Addon id: script.module.codequick 4 | # Addon Provider: willforde 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Kodi Addons\n" 8 | "Report-Msgid-Bugs-To: alanwww1@kodi.org\n" 9 | "POT-Creation-Date: 2016-01-28 21:10+0000\n" 10 | "PO-Revision-Date: 2017-10-22 09:42+0300\n" 11 | "Last-Translator: A. Dambledore\n" 12 | "Language-Team: Eng2Heb\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Language: he_IL\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 | 19 | 20 | # ############################## 21 | # Addon Strings: 32000 - 32199 # 22 | # ############################## 23 | msgctxt "#32001" 24 | msgid "Youtube Channel" 25 | msgstr "ערןץ יו-טיוב" 26 | 27 | msgctxt "#32002" 28 | msgid "Recent Videos" 29 | msgstr "סרטונים אחרונים" 30 | 31 | msgctxt "#32003" 32 | msgid "All Videos" 33 | msgstr "כל קבצי הווידאו" 34 | 35 | msgctxt "#32004" 36 | msgid "Show the most recent videos." 37 | msgstr "" 38 | 39 | msgctxt "#32005" 40 | msgid "Show the next page of content." 41 | msgstr "" 42 | 43 | msgctxt "#32006" 44 | msgid "Search for video content." 45 | msgstr "" 46 | 47 | msgctxt "#32007" 48 | msgid "Show all channel playlists." 49 | msgstr "" 50 | 51 | # #################################### 52 | # Context Menu String: 32200 - 32299 # 53 | # #################################### 54 | 55 | msgctxt "#32201" 56 | msgid "Related Videos" 57 | msgstr "סרטונים קשורים" 58 | 59 | # ############################## 60 | # Error Strings: 32400 - 32599 # 61 | # ############################## 62 | msgctxt "#32401" 63 | msgid "Unable to resolve url" 64 | msgstr "לא ניתן לפתור את כתובת האתרן" 65 | -------------------------------------------------------------------------------- /script.module.codequick/resources/language/resource.language.en_gb/strings.po: -------------------------------------------------------------------------------- 1 | # Kodi Media Center language file 2 | # Addon Name: CodeQuick 3 | # Addon id: script.module.codequick 4 | # Addon Provider: willforde 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Kodi Addons\n" 8 | "Report-Msgid-Bugs-To: alanwww1@kodi.org\n" 9 | "POT-Creation-Date: 2016-01-28 21:10+0000\n" 10 | "PO-Revision-Date: 2017-08-01 04:17+0000\n" 11 | "Last-Translator: Kodi Translation Team\n" 12 | "Language-Team: English (http://www.transifex.com/projects/p/xbmc-addons/language/en/)\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Language: en\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 | 19 | ################################ 20 | # Addon Strings: 32000 - 32199 # 21 | ################################ 22 | 23 | msgctxt "#32001" 24 | msgid "Youtube Channel" 25 | msgstr "" 26 | 27 | msgctxt "#32002" 28 | msgid "Recent Videos" 29 | msgstr "" 30 | 31 | msgctxt "#32003" 32 | msgid "All Videos" 33 | msgstr "" 34 | 35 | msgctxt "#32004" 36 | msgid "Show the most recent videos." 37 | msgstr "" 38 | 39 | msgctxt "#32005" 40 | msgid "Show the next page of content." 41 | msgstr "" 42 | 43 | msgctxt "#32006" 44 | msgid "Search for video content." 45 | msgstr "" 46 | 47 | msgctxt "#32007" 48 | msgid "Show all channel playlists." 49 | msgstr "" 50 | 51 | ###################################### 52 | # Context Menu String: 32200 - 32299 # 53 | ###################################### 54 | 55 | msgctxt "#32201" 56 | msgid "Related Videos" 57 | msgstr "" 58 | 59 | ################################## 60 | # Setting Strings: 32300 - 32399 # 61 | ################################## 62 | 63 | ################################ 64 | # Error Strings: 32400 - 32599 # 65 | ################################ 66 | 67 | msgctxt "#32401" 68 | msgid "Unable to resolve url" 69 | msgstr "" 70 | -------------------------------------------------------------------------------- /script.module.codequick/resources/language/resource.language.fr_fr/strings.po: -------------------------------------------------------------------------------- 1 | # Kodi Media Center language file 2 | # Addon Name: CodeQuick 3 | # Addon id: script.module.codequick 4 | # Addon Provider: willforde 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Kodi Addons\n" 8 | "Report-Msgid-Bugs-To: alanwww1@kodi.org\n" 9 | "POT-Creation-Date: 2016-01-28 21:10+0000\n" 10 | "PO-Revision-Date: 2017-08-01 04:17+0000\n" 11 | "Last-Translator: Kodi Translation Team\n" 12 | "Language-Team: English (http://www.transifex.com/projects/p/xbmc-addons/language/en/)\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Language: en\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 | 19 | ################################ 20 | # Addon Strings: 32000 - 32199 # 21 | ################################ 22 | 23 | msgctxt "#32001" 24 | msgid "Youtube Channel" 25 | msgstr "Chaîne Youtube" 26 | 27 | msgctxt "#32002" 28 | msgid "Recent Videos" 29 | msgstr "Vidéos Résentes" 30 | 31 | msgctxt "#32003" 32 | msgid "All Videos" 33 | msgstr "Toutes les Vidéos" 34 | 35 | msgctxt "#32004" 36 | msgid "Show the most recent videos." 37 | msgstr "Afficher les vidéos les plus récentes." 38 | 39 | msgctxt "#32005" 40 | msgid "Show the next page of content." 41 | msgstr "Afficher le contenu de la page suivante" 42 | 43 | msgctxt "#32006" 44 | msgid "Search for video content." 45 | msgstr "Rechercher du contenu vidéo." 46 | 47 | msgctxt "#32007" 48 | msgid "Show all channel playlists." 49 | msgstr "Afficher toutes les playlists de la chaîne." 50 | 51 | 52 | ###################################### 53 | # Context Menu String: 32200 - 32299 # 54 | ###################################### 55 | 56 | msgctxt "#32201" 57 | msgid "Related Videos" 58 | msgstr "Vidéos Similaires" 59 | 60 | ################################## 61 | # Setting Strings: 32300 - 32399 # 62 | ################################## 63 | 64 | ################################ 65 | # Error Strings: 32400 - 32599 # 66 | ################################ 67 | 68 | msgctxt "#32401" 69 | msgid "Unable to resolve url" 70 | msgstr "Impossible de résoudre l'URL" 71 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright: (c) 2016 - 2018 William Forde (willforde+codequick@gmail.com) 3 | # 4 | # License: GPLv2, see LICENSE for more details 5 | # 6 | # This program is free software; you can redistribute it and/or 7 | # modify it under the terms of the GNU General Public License 8 | # as published by the Free Software Foundation; either version 2 9 | # of the License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program; if not, write to the Free Software 18 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 | 20 | """ 21 | Codequick is a framework for kodi add-on’s. The goal for this framework is to simplify add-on development. 22 | This is achieved by reducing the amount of boilerplate code to a minimum, while automating as many tasks 23 | that can be automated. Ultimately, allowing the developer to focus primarily on scraping content from 24 | websites and passing it to Kodi. 25 | 26 | Github: https://github.com/willforde/script.module.codequick 27 | Documentation: http://scriptmodulecodequick.readthedocs.io/en/latest/?badge=latest 28 | Integrated Unit Tests: https://travis-ci.org/willforde/script.module.codequick 29 | Code Coverage: https://coveralls.io/github/willforde/script.module.codequick?branch=master 30 | Codacy: https://app.codacy.com/app/willforde/script.module.codequick/dashboard 31 | """ 32 | 33 | from __future__ import absolute_import 34 | 35 | # Package imports 36 | from codequick.support import run, get_route 37 | from codequick.resolver import Resolver 38 | from codequick.listing import Listitem 39 | from codequick.script import Script 40 | from codequick.route import Route 41 | from codequick import utils, storage 42 | 43 | __all__ = [ 44 | "run", 45 | "Script", 46 | "Route", 47 | "Resolver", 48 | "Listitem", 49 | "utils", 50 | "storage", 51 | ] 52 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://readthedocs.org/projects/scriptmodulecodequick/badge/?version=stable 2 | :target: http://scriptmodulecodequick.readthedocs.io/en/stable/?badge=stable 3 | 4 | .. image:: https://www.travis-ci.com/willforde/script.module.codequick.svg?branch=master 5 | :target: https://www.travis-ci.com/willforde/script.module.codequick 6 | 7 | .. image:: https://coveralls.io/repos/github/willforde/script.module.codequick/badge.svg?branch=master 8 | :target: https://coveralls.io/github/willforde/script.module.codequick?branch=master 9 | 10 | .. image:: https://api.codeclimate.com/v1/badges/dd5a5656d0136127d74b/maintainability 11 | :target: https://codeclimate.com/github/willforde/script.module.codequick/maintainability 12 | :alt: Maintainability 13 | 14 | 15 | ========= 16 | Codequick 17 | ========= 18 | Codequick is a framework for kodi add-on's. The goal for this framework is to simplify add-on development. 19 | This is achieved by reducing the amount of boilerplate code to a minimum, while automating as many tasks 20 | that can be automated. Ultimately, allowing the developer to focus primarily on scraping content from 21 | websites and passing it to Kodi. 22 | 23 | * Route dispatching (callbacks) 24 | * Callback arguments can be any Python object that can be "pickled" 25 | * Delayed execution (execute code after callbacks have returned results) 26 | * No need to set "isplayable" or "isfolder" properties 27 | * Supports both Python 2 and 3 28 | * Auto sort method selection 29 | * Better error reporting 30 | * Full unicode support 31 | * Sets "mediatype" to "video" or "music" depending on listitem type if not set 32 | * Sets "xbmcplugin.setContent" base off mediatype infolabel. 33 | * Sets "xbmcplugin.setPluginCategory" to the title of current folder 34 | * Sets "thumbnail" to add-on icon image if not set 35 | * Sets "fanart" to add-on fanart image if not set 36 | * Sets "icon" to "DefaultFolder.png" or "DefaultVideo.png’ if not set 37 | * Sets "plot" to the listitem title if not set 38 | * Auto type convertion for (str, unicode, int, float, long) infolables and stream info 39 | * Support for media flags e.g. High definition '720p', audio channels '2.0' 40 | * Reimplementation of the listitem class, that makes heavy use of dictionaries 41 | * Built-in support for saved searches 42 | * Youtube.DL intergration (https://forum.kodi.tv/showthread.php?tid=200877) 43 | * URLQuick intergration (http://urlquick.readthedocs.io/en/stable/) 44 | * Youtube intergration 45 | * Supports use of "reuselanguageinvoker" 46 | 47 | 48 | Documentation 49 | ------------- 50 | Documentation can be found over at ReadTheDocs. 51 | https://scriptmodulecodequick.readthedocs.io/en/stable/ 52 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Codequick’s documentation! 2 | ===================================== 3 | 4 | .. image:: https://readthedocs.org/projects/scriptmodulecodequick/badge/?version=stable 5 | :target: http://scriptmodulecodequick.readthedocs.io/en/latest/?badge=stable 6 | 7 | .. image:: https://travis-ci.org/willforde/script.module.codequick.svg?branch=master 8 | :target: https://travis-ci.org/willforde/script.module.codequick 9 | 10 | .. image:: https://coveralls.io/repos/github/willforde/script.module.codequick/badge.svg?branch=master 11 | :target: https://coveralls.io/github/willforde/script.module.codequick?branch=master 12 | 13 | .. image:: https://api.codacy.com/project/badge/Grade/169396ed505642e9a21e9a4926d31277 14 | :target: https://www.codacy.com/app/willforde/script.module.codequick?utm_source=github.com&utm_medium=referral&utm_content=willforde/script.module.codequick&utm_campaign=Badge_Grade 15 | 16 | 17 | Codequick 18 | --------- 19 | Codequick is a framework for kodi add-on's. The goal for this framework is to simplify add-on development. 20 | This is achieved by reducing the amount of boilerplate code to a minimum, while automating as many tasks 21 | that can be automated. Ultimately, allowing the developer to focus primarily on scraping content from 22 | websites and passing it to Kodi. 23 | 24 | * Route dispatching (callbacks) 25 | * Callback arguments can be any Python object that can be "pickled" 26 | * Delayed execution (execute code after callbacks have returned results) 27 | * No need to set "isplayable" or "isfolder" properties 28 | * Supports both Python 2 and 3 29 | * Auto sort method selection 30 | * Better error reporting 31 | * Full unicode support 32 | * Sets "mediatype" to "video" or "music" depending on listitem type, if not set 33 | * Sets "xbmcplugin.setContent", base off mediatype infolabel. 34 | * Sets "xbmcplugin.setPluginCategory" to the title of current folder 35 | * Sets "thumbnail" to add-on icon image, if not set 36 | * Sets "fanart" to add-on fanart image, if not set 37 | * Sets "icon" to "DefaultFolder.png" or "DefaultVideo.png’, if not set 38 | * Sets "plot" to the listitem title, if not set 39 | * Auto type convertion for (str, unicode, int, float, long) infolables and stream info 40 | * Support for media flags e.g. High definition '720p', audio channels '2.0' 41 | * Reimplementation of the "listitem" class, that makes heavy use of dictionaries 42 | * Youtube.DL intergration (https://forum.kodi.tv/showthread.php?tid=200877) 43 | * URLQuick intergration (http://urlquick.readthedocs.io/en/stable/) 44 | * Built-in support for saved searches 45 | * Youtube intergration 46 | 47 | 48 | Contents 49 | -------- 50 | 51 | .. toctree:: 52 | :maxdepth: 2 53 | :titlesonly: 54 | 55 | tutorial 56 | api/index 57 | examples 58 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | ## [0.9.14] - 2020-08-08 9 | ### Fixed 10 | - Fix watchflags not working. Kodi requires trailing "/". 11 | 12 | 13 | ## [0.9.13] - 2020-07-14 14 | ### Changed 15 | - Deprecate the use of class based callbacks. 16 | - Deprecate ability to pass playable url to listitem.set_callback(), use listitem.set_path() instead. 17 | - Deprecate ability to pass a path to a callback when calling listitem.set_callback(), use a callback reference instead. 18 | - Functions that except callbacks can now except a callback reference object. Better for performance. 19 | 20 | ### Added 21 | - Ability to get and set listitem params as attributes, e.g. item.info.genre = "Science Fiction". 22 | - Listitem objects are now fully pickable. 23 | 24 | 25 | ## [0.9.12] - 2020-06-19 26 | ### Fixed 27 | - Attempt fix for 'import _strptime' failure. 28 | 29 | ### Changed 30 | - Run method now returns the exception that was raise when error occurs 31 | - 'set_callback' now excepts a path to a callback. 32 | - No need to always import callback modules. Modules are auto loaded based on the specified path. 33 | 34 | 35 | ## [0.9.10] - 2019-05-24 36 | ### Changed 37 | - Improved error handling 38 | - Delayed callbacks can now be set to run only when there are no errors, or only when there are errors, or run regardless of errors or not. 39 | - Delayed callbacks can now access the exception that was raised by setting an argument name to exception. 40 | 41 | ### Added 42 | - Added support to auto redirect to the first listitem if there is only one single listitem 43 | 44 | ## [0.9.9] - 2019-04-25 45 | ### Added 46 | - Allow to disable automatic setting of fanart, thumbnail or icon images. 47 | - Allow for plugin paths as folders in set_callback. 48 | - Allow for a callback path to be passed instead of a function 49 | 50 | ## [0.9.8] - 2019-03-11 51 | ### Fixed 52 | - Dailymotion videos not working when using extract_source. 53 | 54 | ## [0.9.7] - 2018-11-30 55 | ### Changed 56 | - Related menu now shows "Related videos" as the category. 57 | 58 | ### Added 59 | - Subtitles can now be added to a listitem by using the "item.subtitles" list. 60 | - "content_type" auto selection can now be disable by setting "plugin.content_type = None". 61 | - "plugin.add_sort_methods" now except a keyword only argument called "disable_autosort", to disable auto sorting. 62 | 63 | ### Fixed 64 | - Watchflags now working with Kodi v18, plugin url path component required a trailing "/". 65 | - Youtube playlist would crash when a playlist contained duplicate videos. 66 | 67 | ### Removed 68 | - "\_\_version__" from \_\_init__.py. 69 | - "Total Execution Time" check as it don't work right when using "reuselanguageinvoker". 70 | - "youtube.CustomRow" class as it was not used anymore. 71 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from addondev import testing 2 | import unittest 3 | import types 4 | 5 | # Testing specific imports 6 | from codequick import utils 7 | 8 | 9 | class Utils(unittest.TestCase): 10 | def test_ensure_unicode_with_bytes(self): 11 | ret = utils.ensure_unicode(b"teststring") 12 | self.assertIsInstance(ret, utils.unicode_type) 13 | self.assertEqual(ret, u"teststring") 14 | 15 | def test_ensure_unicode_with_unicode(self): 16 | ret = utils.ensure_unicode(u"teststring") 17 | self.assertIsInstance(ret, utils.unicode_type) 18 | self.assertEqual(ret, u"teststring") 19 | 20 | def test_ensure_native_str_with_bytes(self): 21 | ret = utils.ensure_native_str(b"teststring") 22 | self.assertIsInstance(ret, str) 23 | self.assertEqual(ret, "teststring") 24 | 25 | def test_ensure_native_str_with_unicode(self): 26 | ret = utils.ensure_native_str(u"teststring") 27 | self.assertIsInstance(ret, str) 28 | self.assertEqual(ret, "teststring") 29 | 30 | def test_ensure_native_str_with_int(self): 31 | ret = utils.ensure_native_str(101) 32 | self.assertIsInstance(ret, str) 33 | self.assertEqual(ret, "101") 34 | 35 | def test_strip_tags(self): 36 | ret = utils.strip_tags('I linked to example.com') 37 | self.assertEqual(ret, "I linked to example.com") 38 | 39 | def test_urljoin_partial(self): 40 | url_constructor = utils.urljoin_partial("https://google.ie/") 41 | self.assertIsInstance(url_constructor, types.FunctionType) 42 | ret = url_constructor("/gmail") 43 | self.assertEqual(ret, "https://google.ie/gmail") 44 | 45 | def test_parse_qs_full(self): 46 | ret = utils.parse_qs("http://example.com/path?q=search&safe=no") 47 | self.assertIsInstance(ret, dict) 48 | self.assertDictEqual(ret, {u"q": u"search", u"safe": u"no"}) 49 | 50 | def test_parse_qs_part(self): 51 | ret = utils.parse_qs("q=search&safe=no") 52 | self.assertIsInstance(ret, dict) 53 | self.assertDictEqual(ret, {u"q": u"search", u"safe": u"no"}) 54 | 55 | def test_parse_qs_fail(self): 56 | with self.assertRaises(ValueError): 57 | utils.parse_qs("q=search&safe=no&safe=yes") 58 | 59 | def test_keyboard(self): 60 | with testing.mock_keyboard("Testing input"): 61 | ret = utils.keyboard("Test") 62 | 63 | self.assertEqual(ret, "Testing input") 64 | 65 | def test_bold(self): 66 | test_string = "text" 67 | ret = utils.bold(test_string) 68 | self.assertEqual("[B]text[/B]", ret, msg="Text was not bolded") 69 | self.assertIsInstance(ret, type(test_string), msg="Text type was unexpectedly converted") 70 | 71 | def test_bold_uni(self): 72 | test_string = u"text" 73 | ret = utils.bold(test_string) 74 | self.assertEqual("[B]text[/B]", ret, msg="Text was not bolded") 75 | self.assertIsInstance(ret, type(test_string), msg="Text type was unexpectedly converted") 76 | 77 | def test_italic(self): 78 | test_string = "text" 79 | ret = utils.italic(test_string) 80 | self.assertEqual("[I]text[/I]", ret, msg="Text was not italic") 81 | self.assertIsInstance(ret, type(test_string), msg="Text type was unexpectedly converted") 82 | 83 | def test_italic_uni(self): 84 | test_string = u"text" 85 | ret = utils.italic(test_string) 86 | self.assertEqual("[I]text[/I]", ret, msg="Text was not italic") 87 | self.assertIsInstance(ret, type(test_string), msg="Text type was unexpectedly converted") 88 | 89 | def test_color(self): 90 | test_string = "text" 91 | ret = utils.color(test_string, color_code="red") 92 | self.assertEqual("[COLOR red]text[/COLOR]", ret, msg="Text was not colorized") 93 | self.assertIsInstance(ret, type(test_string), msg="Text type was unexpectedly converted") 94 | 95 | def test_color_uni(self): 96 | test_string = u"text" 97 | ret = utils.color(test_string, color_code="red") 98 | self.assertEqual("[COLOR red]text[/COLOR]", ret, msg="Text was not colorized") 99 | self.assertIsInstance(ret, type(test_string), msg="Text type was unexpectedly converted") 100 | -------------------------------------------------------------------------------- /tests/test_youtube.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sqlite3 3 | import inspect 4 | import xbmc 5 | import os 6 | 7 | from codequick import youtube 8 | from codequick.support import dispatcher 9 | 10 | 11 | def clean_cache(): 12 | """Remvoe the youtube cache file""" 13 | if os.path.exists(youtube.CACHEFILE): 14 | os.remove(youtube.CACHEFILE) 15 | 16 | 17 | def route_caller(callback, *args, **kwargs): 18 | dispatcher.selector = callback.route.path 19 | try: 20 | results = callback.test(*args, **kwargs) 21 | if inspect.isgenerator(results): 22 | return list(results) 23 | else: 24 | return results 25 | finally: 26 | dispatcher.reset() 27 | 28 | 29 | # noinspection PyTypeChecker 30 | class Testcallbacks(unittest.TestCase): 31 | @classmethod 32 | def setUpClass(cls): 33 | clean_cache() 34 | 35 | def test_playlist_uploads_no_cache(self): 36 | ret = route_caller(youtube.playlist, "UCaWd5_7JhbQBe4dknZhsHJg") 37 | self.assertGreaterEqual(len(ret), 50) 38 | 39 | def test_playlist_uploads_with_cache(self): 40 | ret = route_caller(youtube.playlist, "UCaWd5_7JhbQBe4dknZhsHJg") 41 | self.assertGreaterEqual(len(ret), 50) 42 | 43 | def test_playlist_playlist_single_page(self): 44 | ret = route_caller(youtube.playlist, "PLmZTDWJGfRq3dT8teArT8RGg-SPpXErHK") 45 | self.assertGreaterEqual(len(ret), 10) 46 | 47 | def test_playlist_unlisted(self): 48 | ret = route_caller(youtube.playlist, "PLh6dr2Pr1VQnPdF29tkfQPgCBJPFnKYEV") 49 | self.assertGreaterEqual(len(ret), 17) 50 | 51 | def test_playlist_playlist_muilti_page(self): 52 | ret = route_caller(youtube.playlist, "PL8mG-RkN2uTx1lbFS8z8wRYS3RrHCp8TG", loop=False) 53 | self.assertGreaterEqual(len(ret), 49) 54 | 55 | def test_playlist_playlist_loop(self): 56 | ret = route_caller(youtube.playlist, "PL8mG-RkN2uTx1lbFS8z8wRYS3RrHCp8TG", loop=True) 57 | self.assertGreaterEqual(len(ret), 66) 58 | 59 | def test_related(self): 60 | ret = route_caller(youtube.related, "-QEXPO9zgX8") 61 | self.assertGreaterEqual(len(ret), 50) 62 | 63 | def test_playlists(self): 64 | ret = route_caller(youtube.playlists, "UCaWd5_7JhbQBe4dknZhsHJg") 65 | self.assertGreaterEqual(len(ret), 50) 66 | 67 | def test_playlists_with_uploade_id(self): 68 | with self.assertRaises(ValueError): 69 | route_caller(youtube.playlists, "UUewxof_QqDdqVdXY1BaDtqQ") 70 | 71 | def test_playlists_disable_all_link(self): 72 | ret = route_caller(youtube.playlists, "UCaWd5_7JhbQBe4dknZhsHJg", show_all=False) 73 | self.assertGreaterEqual(len(ret), 50) 74 | 75 | def test_bad_channel_id(self): 76 | with self.assertRaises(KeyError): 77 | route_caller(youtube.playlist, "UCad5_7JhQBe4dknZhsJg") 78 | 79 | 80 | class TestAPIControl(unittest.TestCase): 81 | @classmethod 82 | def setUpClass(cls): 83 | clean_cache() 84 | 85 | def setUp(self): 86 | self.api = youtube.APIControl() 87 | 88 | def tearDown(self): 89 | try: 90 | self.api.close() 91 | except sqlite3.ProgrammingError: 92 | pass 93 | self.api = None 94 | dispatcher.reset() 95 | 96 | def test_valid_playlistid(self): 97 | ret = self.api.valid_playlistid("UCaWd5_7JhbQBe4dknZhsHJg") 98 | self.assertEqual(ret, "UUaWd5_7JhbQBe4dknZhsHJg") 99 | 100 | def test_valid_playlistid_unknown(self): 101 | with self.assertRaises(KeyError): 102 | self.api.valid_playlistid("UCad5_7JhQBe4dknZhsJg") 103 | 104 | def test_valid_playlistid_invalid(self): 105 | with self.assertRaises(ValueError): 106 | self.api.valid_playlistid("-QEXPO9zgX8") 107 | 108 | def test_convert_duration_seconds(self): 109 | test_match = [(u'42', u'S')] 110 | ret = self.api._convert_duration(test_match) 111 | self.assertEqual(ret, 42) 112 | 113 | def test_convert_duration_minutes(self): 114 | test_match = [(u'11', u'M'), (u'42', u'S')] 115 | ret = self.api._convert_duration(test_match) 116 | self.assertEqual(ret, 702) 117 | 118 | def test_convert_duration_hours(self): 119 | test_match = [(u'1', u'H'), (u'11', u'M'), (u'42', u'S')] 120 | ret = self.api._convert_duration(test_match) 121 | self.assertEqual(ret, 4302) 122 | 123 | 124 | class TestDB(unittest.TestCase): 125 | def setUp(self): 126 | self.db = youtube.Database() 127 | 128 | def tearDown(self): 129 | try: 130 | self.db.close() 131 | except sqlite3.ProgrammingError: 132 | pass 133 | self.db = None 134 | dispatcher.reset() 135 | 136 | def test_close(self): 137 | self.db.close() 138 | 139 | def test_cleanup_no_run(self): 140 | self.db.cleanup() 141 | 142 | def test_cleanup_run(self): 143 | org_cur = self.db.cur 144 | 145 | class Cur(object): 146 | @staticmethod 147 | def execute(_): 148 | class Fetchone(object): 149 | @staticmethod 150 | def fetchone(): 151 | return [15000] 152 | 153 | self.db.cur = org_cur 154 | return Fetchone() 155 | 156 | self.db.cur = Cur() 157 | 158 | try: 159 | self.db.cleanup() 160 | finally: 161 | self.db.cur = org_cur 162 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | # 4 | # CodeQuick documentation build configuration file, created by 5 | # sphinx-quickstart on Sun Aug 6 02:34:34 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | from addondev import initializer 16 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('../script.module.codequick/lib')) 23 | initializer(os.path.abspath('../script.module.codequick')) 24 | 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = 'CodeQuick' 51 | copyright = '2017, William Forde' 52 | author = 'William Forde' 53 | 54 | # The version info for the project you're documenting, acts as replacement for 55 | # |version| and |release|, also used in various other places throughout the 56 | # built documents. 57 | # 58 | # The short X.Y version. 59 | version = '0.9.0' 60 | # The full version, including alpha/beta/rc tags. 61 | release = '0.9.0' 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = 'sphinx' 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ---------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'sphinx_rtd_theme' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = ['_static'] 99 | 100 | # Custom sidebar templates, must be a dictionary that maps document names 101 | # to template names. 102 | # 103 | # This is required for the alabaster theme 104 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 105 | html_sidebars = { 106 | '**': [ 107 | 'about.html', 108 | 'navigation.html', 109 | 'relations.html', # needs 'show_related': True theme option to display 110 | 'searchbox.html', 111 | 'donate.html', 112 | ] 113 | } 114 | 115 | 116 | # -- Options for HTMLHelp output ------------------------------------------ 117 | 118 | # Output file base name for HTML help builder. 119 | htmlhelp_basename = 'CodeQuickdoc' 120 | 121 | 122 | # -- Options for LaTeX output --------------------------------------------- 123 | 124 | latex_elements = { 125 | # The paper size ('letterpaper' or 'a4paper'). 126 | # 127 | # 'papersize': 'letterpaper', 128 | 129 | # The font size ('10pt', '11pt' or '12pt'). 130 | # 131 | # 'pointsize': '10pt', 132 | 133 | # Additional stuff for the LaTeX preamble. 134 | # 135 | # 'preamble': '', 136 | 137 | # Latex figure (float) alignment 138 | # 139 | # 'figure_align': 'htbp', 140 | } 141 | 142 | # Grouping the document tree into LaTeX files. List of tuples 143 | # (source start file, target name, title, 144 | # author, documentclass [howto, manual, or own class]). 145 | latex_documents = [ 146 | (master_doc, 'CodeQuick.tex', 'CodeQuick Documentation', 147 | 'William Forde', 'manual'), 148 | ] 149 | 150 | 151 | # -- Options for manual page output --------------------------------------- 152 | 153 | # One entry per manual page. List of tuples 154 | # (source start file, name, description, authors, manual section). 155 | man_pages = [ 156 | (master_doc, 'codequick', 'CodeQuick Documentation', 157 | [author], 1) 158 | ] 159 | 160 | 161 | # -- Options for Texinfo output ------------------------------------------- 162 | 163 | # Grouping the document tree into Texinfo files. List of tuples 164 | # (source start file, target name, title, author, 165 | # dir menu entry, description, category) 166 | texinfo_documents = [ 167 | (master_doc, 'CodeQuick', 'CodeQuick Documentation', 168 | author, 'CodeQuick', 'One line description of project.', 169 | 'Miscellaneous'), 170 | ] 171 | 172 | # Example configuration for intersphinx: refer to the Python standard library. 173 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} 174 | 175 | autodoc_member_order = 'bysource' 176 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/search.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | # Standard Library Imports 5 | from hashlib import sha1 6 | 7 | # Package imports 8 | from codequick import localized 9 | from codequick.storage import PersistentDict 10 | from codequick.support import dispatcher 11 | from codequick.listing import Listitem 12 | from codequick.utils import keyboard, ensure_unicode 13 | from codequick.route import Route, validate_listitems 14 | 15 | try: 16 | # noinspection PyPep8Naming 17 | import cPickle as pickle 18 | except ImportError: # pragma: no cover 19 | import pickle 20 | 21 | # Name of the database file 22 | SEARCH_DB = u"_new_searches.pickle" 23 | 24 | 25 | class Search(object): 26 | def __init__(self, plugin, extra_params): 27 | # The saved search persistent storage 28 | self.db = search_db = PersistentDict(SEARCH_DB) 29 | plugin.register_delayed(search_db.close) 30 | 31 | # Fetch saved data specific to this session 32 | session_hash = self.hash_params(extra_params) 33 | self.data = search_db.setdefault(session_hash, []) 34 | 35 | def __iter__(self): 36 | return iter(self.data) 37 | 38 | def __contains__(self, item): 39 | return item in self.data 40 | 41 | def __bool__(self): 42 | return bool(self.data) 43 | 44 | def __nonzero__(self): 45 | return bool(self.data) 46 | 47 | def remove(self, item): # type: (str) -> None 48 | self.data.remove(item) 49 | self.db.flush() 50 | 51 | def append(self, item): # type: (str) -> None 52 | self.data.append(item) 53 | self.db.flush() 54 | 55 | @staticmethod 56 | def hash_params(data): 57 | # Convert dict of params into a sorted list of key, value pairs 58 | sorted_dict = sorted(data.items()) 59 | 60 | # Pickle the sorted dict so we can hash the contents 61 | content = pickle.dumps(sorted_dict, protocol=2) 62 | return ensure_unicode(sha1(content).hexdigest()) 63 | 64 | 65 | @Route.register 66 | def saved_searches(plugin, remove_entry=None, search=False, first_load=False, **extras): 67 | """ 68 | Callback used to list all saved searches for the addon that called it. 69 | 70 | Useful to add search support to addon and will also keep track of previous searches. 71 | Also contains option via context menu to remove old search terms. 72 | 73 | :param Route plugin: Tools related to Route callbacks. 74 | :param remove_entry: [opt] Search term to remove from history. 75 | :param search: [opt] When set to True the search input box will appear. 76 | :param first_load: Only True when callback is called for the first time, allowes for search box to appear on load. 77 | :param extras: Any extra params to farward on to the next callback 78 | :returns: A list of search terms or the search results if loaded for the first time. 79 | """ 80 | searchdb = Search(plugin, extras) 81 | 82 | # Remove search term from saved searches 83 | if remove_entry and remove_entry in searchdb: 84 | searchdb.remove(remove_entry) 85 | plugin.update_listing = True 86 | 87 | # Show search dialog if search argument is True, or if there is no search term saved 88 | # First load is used to only allow auto search to work when first loading the saved search container. 89 | # Fixes an issue when there is no saved searches left after removing them. 90 | elif search or (first_load and not searchdb): 91 | search_term = keyboard(plugin.localize(localized.ENTER_SEARCH_STRING)) 92 | if search_term: 93 | return redirect_search(plugin, searchdb, search_term, extras) 94 | elif not searchdb: 95 | return False 96 | else: 97 | plugin.update_listing = True 98 | 99 | # List all saved search terms 100 | return list_terms(plugin, searchdb, extras) 101 | 102 | 103 | def redirect_search(plugin, searchdb, search_term, extras): 104 | """ 105 | Checks if searh term returns valid results before adding to saved searches. 106 | Then directly farward the results to kodi. 107 | 108 | :param Route plugin: Tools related to Route callbacks. 109 | :param Search searchdb: Search DB 110 | :param str search_term: The serch term used to search for results. 111 | :param dict extras: Extra parameters that will be farwarded on to the callback function. 112 | :return: List if valid search results 113 | """ 114 | plugin.params[u"_title_"] = title = search_term.title() 115 | plugin.category = title 116 | callback_params = extras.copy() 117 | callback_params["search_query"] = search_term 118 | 119 | # We switch selector to redirected callback to allow next page to work properly 120 | route = callback_params.pop("_route") 121 | dispatcher.selector = route 122 | 123 | # Fetch search results from callback 124 | func = dispatcher.get_route().function 125 | listitems = func(plugin, **callback_params) 126 | 127 | # Check that we have valid listitems 128 | valid_listitems = validate_listitems(listitems) 129 | 130 | # Add the search term to database and return the list of results 131 | if valid_listitems: 132 | if search_term not in searchdb: # pragma: no branch 133 | searchdb.append(search_term) 134 | 135 | return valid_listitems 136 | else: 137 | # Return False to indicate failure 138 | return False 139 | 140 | 141 | def list_terms(plugin, searchdb, extras): 142 | """ 143 | List all saved searches. 144 | 145 | :param Route plugin: Tools related to Route callbacks. 146 | :param Search searchdb: Search DB 147 | :param dict extras: Extra parameters that will be farwarded on to the context.container. 148 | 149 | :returns: A generator of listitems. 150 | :rtype: :class:`types.GeneratorType` 151 | """ 152 | # Add listitem for adding new search terms 153 | search_item = Listitem() 154 | search_item.label = u"[B]%s[/B]" % plugin.localize(localized.SEARCH) 155 | search_item.set_callback(saved_searches, search=True, **extras) 156 | search_item.art.global_thumb("search_new.png") 157 | yield search_item 158 | 159 | # Set the callback function to the route that was given 160 | callback_params = extras.copy() 161 | route = callback_params.pop("_route") 162 | callback = dispatcher.get_route(route).callback 163 | 164 | # Prefetch the localized string for the context menu lable 165 | str_remove = plugin.localize(localized.REMOVE) 166 | 167 | # List all saved searches 168 | for search_term in searchdb: 169 | item = Listitem() 170 | item.label = search_term.title() 171 | 172 | # Creatre Context Menu item for removing search term 173 | item.context.container(saved_searches, str_remove, remove_entry=search_term, **extras) 174 | 175 | # Update params with full url and set the callback 176 | item.params.update(callback_params, search_query=search_term) 177 | item.set_callback(callback) 178 | yield item 179 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import shutil 3 | import pickle 4 | import time 5 | import os 6 | 7 | # Testing specific imports 8 | from codequick import storage 9 | 10 | 11 | class StorageDict(unittest.TestCase): 12 | def __init__(self, *args, **kwargs): 13 | super(StorageDict, self).__init__(*args, **kwargs) 14 | self.filename = "dictfile.pickle" 15 | self.path = os.path.join(storage.profile_dir, self.filename) 16 | 17 | def setUp(self): 18 | if os.path.exists(self.path): 19 | os.remove(self.path) 20 | 21 | def test_create_filename_part(self): 22 | with storage.PersistentDict(self.filename) as db: 23 | self.assertFalse(db) 24 | self.assertIsNone(db._stream) 25 | 26 | def test_create_filename_full(self): 27 | with storage.PersistentDict(self.path) as db: 28 | self.assertFalse(db) 29 | self.assertIsNone(db._stream) 30 | 31 | def test_flush_with_data(self): 32 | with storage.PersistentDict(self.filename) as db: 33 | db["test"] = "data" 34 | db.flush() 35 | self.assertIn("test", db) 36 | 37 | def test_flush_no_data(self): 38 | with storage.PersistentDict(self.filename) as db: 39 | db.flush() 40 | 41 | def test_flush_with_missing_dir(self): 42 | shutil.rmtree(storage.profile_dir) 43 | with storage.PersistentDict(self.filename) as db: 44 | db["test"] = "data" 45 | db.flush() 46 | self.assertIn("test", db) 47 | 48 | def test_persistents(self): 49 | with storage.PersistentDict(self.filename) as db: 50 | db["persistent"] = "true" 51 | self.assertIn("persistent", db) 52 | 53 | with storage.PersistentDict(self.filename) as db: 54 | self.assertIn("persistent", db) 55 | self.assertEqual(db["persistent"], "true") 56 | 57 | def test_get(self): 58 | with storage.PersistentDict(self.filename) as db: 59 | db.update({"one": 1, "two": 2}) 60 | self.assertEqual(db["two"], 2) 61 | 62 | def test_set(self): 63 | with storage.PersistentDict(self.filename) as db: 64 | db["one"] = 1 65 | self.assertIn("one", db) 66 | self.assertEqual(db["one"], 1) 67 | 68 | def test_del(self): 69 | with storage.PersistentDict(self.filename) as db: 70 | db.update({"one": 1, "two": 2}) 71 | del db["one"] 72 | self.assertNotIn("one", db) 73 | self.assertIn("two", db) 74 | 75 | def test_len(self): 76 | with storage.PersistentDict(self.filename) as db: 77 | db.update({"one": 1, "two": 2}) 78 | self.assertEqual(len(db), 2) 79 | 80 | def test_iter(self): 81 | with storage.PersistentDict(self.filename) as db: 82 | db.update({"one": 1, "two": 2}) 83 | data = list(iter(db)) 84 | self.assertEqual(len(data), 2) 85 | self.assertIn("one", data) 86 | self.assertIn("two", data) 87 | 88 | def test_items(self): 89 | with storage.PersistentDict(self.filename) as db: 90 | db.update({"one": 1, "two": 2}) 91 | data = list(db.items()) 92 | self.assertEqual(len(data), 2) 93 | expected = [("one", 1), ("two", 2)] 94 | for item in data: 95 | self.assertIn(item, expected) 96 | 97 | def test_version_convert(self): 98 | with open(self.path, "wb") as db: 99 | pickle.dump({"one": 1, "two": 2}, db, protocol=2) 100 | 101 | with storage.PersistentDict(self.filename) as db: 102 | self.assertIn("one", db) 103 | self.assertIn("two", db) 104 | 105 | def test_ttl(self): 106 | with open(self.path, "wb") as db: 107 | pickle.dump({"one": 1, "two": 2}, db, protocol=2) 108 | 109 | with storage.PersistentDict(self.filename) as db: 110 | self.assertIn("one", db) 111 | self.assertIn("two", db) 112 | 113 | time.sleep(2) 114 | with storage.PersistentDict(self.filename, 1) as db: 115 | self.assertNotIn("one", db) 116 | self.assertNotIn("two", db) 117 | 118 | 119 | class StorageList(unittest.TestCase): 120 | def __init__(self, *args, **kwargs): 121 | super(StorageList, self).__init__(*args, **kwargs) 122 | self.filename = "listfile.pickle" 123 | self.path = os.path.join(storage.profile_dir, self.filename) 124 | 125 | def setUp(self): 126 | if os.path.exists(self.path): 127 | os.remove(self.path) 128 | 129 | def test_create_filename_part(self): 130 | with storage.PersistentList(self.filename) as db: 131 | self.assertFalse(db) 132 | self.assertIsNone(db._stream) 133 | 134 | def test_create_filename_full(self): 135 | with storage.PersistentList(self.path) as db: 136 | self.assertFalse(db) 137 | self.assertIsNone(db._stream) 138 | 139 | def test_flush_with_data(self): 140 | with storage.PersistentList(self.filename) as db: 141 | db.append("data") 142 | db.flush() 143 | self.assertIn("data", db) 144 | 145 | def test_flush_no_data(self): 146 | with storage.PersistentList(self.filename) as db: 147 | db.flush() 148 | 149 | def test_flush_with_missing_dir(self): 150 | shutil.rmtree(storage.profile_dir) 151 | with storage.PersistentList(self.filename) as db: 152 | db.append("data") 153 | db.flush() 154 | self.assertIn("data", db) 155 | 156 | def test_persistents(self): 157 | with storage.PersistentList(self.filename) as db: 158 | db.append("persistent") 159 | self.assertIn("persistent", db) 160 | 161 | with storage.PersistentList(self.filename) as db: 162 | db.append("persistent2") 163 | self.assertIn("persistent", db) 164 | self.assertIn("persistent2", db) 165 | 166 | def test_get(self): 167 | with storage.PersistentList(self.filename) as db: 168 | db.append("data") 169 | self.assertEqual(db[0], "data") 170 | 171 | def test_set(self): 172 | with storage.PersistentList(self.filename) as db: 173 | db.append("old") 174 | db[0] = "data" 175 | self.assertIn("data", db) 176 | self.assertEqual(db[0], "data") 177 | self.assertNotIn("old", db) 178 | 179 | def test_del(self): 180 | with storage.PersistentList(self.filename) as db: 181 | db.append("data") 182 | del db[0] 183 | self.assertNotIn("data", db) 184 | 185 | def test_insert(self): 186 | with storage.PersistentList(self.filename) as db: 187 | db.insert(0, "one") 188 | self.assertEqual(db[0], "one") 189 | 190 | def test_len(self): 191 | with storage.PersistentList(self.filename) as db: 192 | db.extend(["one", "two"]) 193 | self.assertEqual(len(db), 2) 194 | 195 | def test_version_convert(self): 196 | with open(self.path, "wb") as db: 197 | pickle.dump(["one", "two"], db, protocol=2) 198 | 199 | with storage.PersistentList(self.filename) as db: 200 | self.assertIn("one", db) 201 | self.assertIn("two", db) 202 | 203 | def test_ttl(self): 204 | with open(self.path, "wb") as db: 205 | pickle.dump(["one", "two"], db, protocol=2) 206 | 207 | with storage.PersistentList(self.filename) as db: 208 | self.assertIn("one", db) 209 | self.assertIn("two", db) 210 | 211 | time.sleep(2) 212 | with storage.PersistentList(self.filename, 1) as db: 213 | self.assertNotIn("one", db) 214 | self.assertNotIn("two", db) 215 | -------------------------------------------------------------------------------- /tests/test_script.py: -------------------------------------------------------------------------------- 1 | from addondev.testing import data_log 2 | import unittest 3 | 4 | from codequick import script 5 | 6 | 7 | class Addon(object): 8 | def __init__(self, id=u"testdata"): 9 | self.settings = {} 10 | self.default = id 11 | 12 | def getSetting(self, id): 13 | return type(u"")(self.settings.get(id, self.default)) 14 | 15 | def setSetting(self, id, value): 16 | self.settings[id] = value 17 | 18 | 19 | class MockLogger(object): 20 | def __init__(self): 21 | self.record = "" 22 | self.lvl = 0 23 | 24 | self.org_logger = script.addon_logger 25 | script.addon_logger = self 26 | 27 | def log(self, lvl, msg, *args): 28 | self.lvl = lvl 29 | if args: 30 | self.record = msg % args 31 | else: 32 | self.record = msg 33 | 34 | def __enter__(self): 35 | return self 36 | 37 | def __exit__(self, *_): 38 | script.addon_logger = self.org_logger 39 | 40 | 41 | class Settings(unittest.TestCase): 42 | def setUp(self): 43 | self.org_addon_data = script.addon_data 44 | self.org_xbmcaddon = script.xbmcaddon.Addon 45 | 46 | script.addon_data = Addon() 47 | script.xbmcaddon.Addon = Addon 48 | 49 | self.settings = script.Settings() 50 | 51 | def tearDown(self): 52 | script.addon_data = self.org_addon_data 53 | script.xbmcaddon.Addon = self.org_xbmcaddon 54 | 55 | def test_getter(self): 56 | string = self.settings["tester"] 57 | self.assertEqual(string, "testdata") 58 | 59 | def test_setter(self): 60 | self.settings["tester"] = "newdata" 61 | string = self.settings["tester"] 62 | self.assertEqual(string, "newdata") 63 | 64 | def test_deleter(self): 65 | self.settings["tester"] = "newdata" 66 | del self.settings["tester"] 67 | string = self.settings["tester"] 68 | self.assertEqual(string, "") 69 | 70 | def test_get_string(self): 71 | self.settings["tester"] = "newdata" 72 | data = self.settings.get_string("tester") 73 | self.assertIsInstance(data, type(u"")) 74 | self.assertEqual(data, "newdata") 75 | 76 | def test_get_boolean(self): 77 | self.settings["tester"] = "true" 78 | data = self.settings.get_boolean("tester") 79 | self.assertIsInstance(data, bool) 80 | self.assertEqual(data, True) 81 | 82 | def test_get_int(self): 83 | self.settings["tester"] = "999" 84 | data = self.settings.get_int("tester") 85 | self.assertIsInstance(data, int) 86 | self.assertEqual(data, 999) 87 | 88 | def test_get_number(self): 89 | self.settings["tester"] = "1.5" 90 | data = self.settings.get_number("tester") 91 | self.assertIsInstance(data, float) 92 | self.assertEqual(data, 1.5) 93 | 94 | def test_get_string_addon(self): 95 | data = self.settings.get_string("tester", addon_id="newdata") 96 | self.assertIsInstance(data, type(u"")) 97 | self.assertEqual(data, "newdata") 98 | 99 | def test_get_boolean_addon(self): 100 | data = self.settings.get_boolean("tester", addon_id="true") 101 | self.assertIsInstance(data, bool) 102 | self.assertEqual(data, True) 103 | 104 | def test_get_int_addon(self): 105 | data = self.settings.get_int("tester", addon_id="999") 106 | self.assertIsInstance(data, int) 107 | self.assertEqual(data, 999) 108 | 109 | def test_get_number_addon(self): 110 | data = self.settings.get_number("tester", addon_id="1.5") 111 | self.assertIsInstance(data, float) 112 | self.assertEqual(data, 1.5) 113 | 114 | 115 | class Script(unittest.TestCase): 116 | def setUp(self): 117 | self.script = script.Script() 118 | 119 | def test_register_metacall(self): 120 | def tester(): 121 | pass 122 | 123 | self.script.register_delayed(tester) 124 | for callback, _, _, _ in script.dispatcher.registered_delayed: 125 | if callback is tester: 126 | self.assertTrue(True, "") 127 | break 128 | else: 129 | self.assertTrue(False) 130 | 131 | def test_log_noarg(self): 132 | with MockLogger() as logger: 133 | self.script.log("test msg") 134 | self.assertEqual(logger.record, "test msg") 135 | 136 | def test_log_noarg_lvl(self): 137 | with MockLogger() as logger: 138 | self.script.log("test msg", lvl=20) 139 | self.assertEqual(logger.record, "test msg") 140 | self.assertEqual(logger.lvl, 20) 141 | 142 | def test_log_args(self): 143 | with MockLogger() as logger: 144 | self.script.log("test %s", ["msg"]) 145 | self.assertEqual(logger.record, "test msg") 146 | 147 | def test_notify(self): 148 | self.script.notify("test header", "test msg", icon=self.script.NOTIFY_INFO) 149 | data = data_log["notifications"][-1] 150 | self.assertEqual(data[0], "test header") 151 | self.assertEqual(data[1], "test msg") 152 | self.assertEqual(data[2], "info") 153 | 154 | def test_localize(self): 155 | self.assertEqual(self.script.localize(30001), "") 156 | 157 | def test_localize_allvideos(self): 158 | self.assertEqual("All Videos", self.script.localize(32003)) 159 | 160 | def test_localize_allvideos_gettext(self): 161 | org_map = script.string_map.copy() 162 | try: 163 | script.string_map["All Videos"] = 32003 164 | self.assertEqual(self.script.localize("All Videos"), "All Videos") 165 | finally: 166 | script.string_map.clear() 167 | script.string_map.update(org_map) 168 | 169 | def test_localize_allvideos_gettext_non_exist(self): 170 | with self.assertRaises(KeyError): 171 | data = self.script.localize("All Videos") 172 | print("###########") 173 | print(data) 174 | 175 | def test_localize_nodata(self): 176 | self.assertEqual(self.script.localize(33077), "No data found!") 177 | 178 | def test_get_author(self): 179 | self.assertEqual(self.script.get_info("author"), "willforde") 180 | 181 | def test_get_changelog(self): 182 | self.assertEqual(self.script.get_info("changelog"), "") 183 | 184 | def test_get_description(self): 185 | self.assertTrue(self.script.get_info("description").startswith( 186 | "Codequick is a framework for kodi add-on's. The goal of this")) 187 | 188 | def test_get_disclaimer(self): 189 | self.assertEqual(self.script.get_info("disclaimer"), "") 190 | 191 | def test_get_fanart(self): 192 | self.assertTrue(self.script.get_info("fanart").endswith("script.module.codequick/fanart.jpg")) 193 | 194 | def test_get_icon(self): 195 | self.assertTrue(self.script.get_info("icon").endswith("script.module.codequick/resources/icon.png")) 196 | 197 | def test_get_id(self): 198 | self.assertEqual(self.script.get_info("id"), "script.module.codequick") 199 | 200 | def test_get_name(self): 201 | self.assertEqual(self.script.get_info("name"), "CodeQuick") 202 | 203 | def test_get_path(self): 204 | self.assertTrue(self.script.get_info("path").endswith("script.module.codequick")) 205 | 206 | def test_get_profile(self): 207 | self.assertTrue(self.script.get_info("profile").endswith("userdata/addon_data/script.module.codequick")) 208 | 209 | def test_get_stars(self): 210 | self.assertEqual(self.script.get_info("stars"), "-1") 211 | 212 | def test_get_summary(self): 213 | self.assertEqual(self.script.get_info("summary"), "Framework for creating kodi add-on's.") 214 | 215 | def test_get_type(self): 216 | self.assertEqual(self.script.get_info("type"), "xbmc.python.module") 217 | 218 | def test_get_version(self): 219 | self.assertIsInstance(self.script.get_info("version"), type(u"")) 220 | 221 | def test_get_name_addon(self): 222 | self.assertEqual(self.script.get_info("name", addon_id="script.module.codequick"), "CodeQuick") 223 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Tutorial 3 | ######## 4 | Here we will document the creation of an "add-on". 5 | In this instance, “plugin.video.metalvideo”. This will be a simplified version of the full add-on 6 | that can be found over at: https://github.com/willforde/plugin.video.metalvideo 7 | 8 | First of all, import the required “Codequick” components. 9 | 10 | * :class:`Route` will be used to list folder items. 11 | * :class:`Resolver` will be used to resolve video URLs. 12 | * :class:`Listitem` is used to create "items" within Kodi. 13 | * :mod:`utils` is a module, containing some useful functions. 14 | * :func:`run` is the function that controls the execution of the add-on. 15 | 16 | .. code-block:: python 17 | 18 | from codequick import Route, Resolver, Listitem, utils, run 19 | 20 | 21 | Next we will import "urlquick", which is a "light-weight" HTTP client with a "requests" like interface, 22 | featuring caching support. 23 | 24 | .. code-block:: python 25 | 26 | import urlquick 27 | 28 | Now, we will use :func:`utils.urljoin_partial` to create a URL constructor 29 | with the "base" URL of the site. This is use to convert relative URLs to absolute URLs. 30 | Normally HTML is full of relative URLs and this makes it easier to work with them, 31 | guaranteeing that you will always have an absolute URL to use. 32 | 33 | .. code-block:: python 34 | 35 | # Base url constructor 36 | url_constructor = utils.urljoin_partial("https://metalvideo.com") 37 | 38 | Next we will create the "Root" function which will be the starting point for the add-on. 39 | It is very important that the "Root" function is called "root". This function will first have to be registered 40 | as a "callback" function. Since this is a function that will return "listitems", this will be registered as a 41 | :class:`Route` callback. It is expected that a :class:`Route` 42 | callback should return a "generator" or "list", of :class:`codequick.Listitem` objects. 43 | The first argument that will be passed to a :class:`Route` callback, will always be the 44 | :class:`Route` instance. 45 | 46 | This "callback" will parse the list of “Music Video Categories” available on: http://metalvideo.com, 47 | This will return a "generator" of "listitems" linking to a sub-directory of videos within that category. 48 | Parsing of the HTML source will be done using "HTMLement" which is integrated into the "urlquick" request response. 49 | 50 | 51 | .. seealso:: URLQuick: http://urlquick.readthedocs.io/en/stable/ 52 | 53 | HTMLement: http://python-htmlement.readthedocs.io/en/stable/ 54 | 55 | .. code-block:: python 56 | 57 | @Route.register 58 | def root(plugin): 59 | # Request the online resource 60 | url = url_constructor("/browse.html") 61 | resp = urlquick.get(url) 62 | 63 | # Filter source down to required section by giving the name and 64 | # attributes of the element containing the required data. 65 | # It's a lot faster to limit the parser to required section. 66 | root_elem = resp.parse("div", attrs={"id": "primary"}) 67 | 68 | # Parse each category 69 | for elem in root_elem.iterfind("ul/li"): 70 | item = Listitem() 71 | 72 | # The image tag contains both the image url and title 73 | img = elem.find(".//img") 74 | 75 | # Set the thumbnail image 76 | item.art["thumb"] = img.get("src") 77 | 78 | # Set the title 79 | item.label = img.get("alt") 80 | 81 | # Fetch the url 82 | url = elem.find("div/a").get("href") 83 | 84 | # This will set the callback that will be called when listitem is activated. 85 | # 'video_list' is the route callback function that we will create later. 86 | # The 'url' argument is the url of the category that will be passed 87 | # to the 'video_list' callback. 88 | item.set_callback(video_list, url=url) 89 | 90 | # Return the listitem as a generator. 91 | yield item 92 | 93 | Now, we can create the "video parser" callback that will return "playable" listitems. Since this is another 94 | function that will return listitems, it will be registered as a :class:`Route` callback. 95 | 96 | .. code-block:: python 97 | 98 | @Route.register 99 | def video_list(plugin, url): 100 | # Request the online resource. 101 | url = url_constructor(url) 102 | resp = urlquick.get(url) 103 | 104 | # Parse the html source 105 | root_elem = resp.parse("div", attrs={"class": "primary-content"}) 106 | 107 | # Parse each video 108 | for elem in root_elem.find("ul").iterfind("./li/div"): 109 | item = Listitem() 110 | 111 | # Set the thumbnail image of the video. 112 | item.art["thumb"] = elem.find(".//img").get("src") 113 | 114 | # Set the duration of the video 115 | item.info["duration"] = elem.find("span/span/span").text.strip() 116 | 117 | # Set thel plot info 118 | item.info["plot"] = elem.find("p").text.strip() 119 | 120 | # Set view count 121 | views = elem.find("./div/span[@class='pm-video-attr-numbers']/small").text 122 | item.info["count"] = views.split(" ", 1)[0].strip() 123 | 124 | # Set the date that the video was published 125 | date = elem.find(".//time[@datetime]").get("datetime") 126 | date = date.split("T", 1)[0] 127 | item.info.date(date, "%Y-%m-%d") # 2018-10-19 128 | 129 | # Url to video & name 130 | a_tag = elem.find("h3/a") 131 | url = a_tag.get("href") 132 | item.label = a_tag.text 133 | 134 | # Extract the artist name from the title 135 | item.info["artist"] = [a_tag.text.split("-", 1)[0].strip()] 136 | 137 | # 'play_video' is the resolver callback function that we will create later. 138 | # The 'url' argument is the url of the video that will be passed 139 | # to the 'play_video' resolver callback. 140 | item.set_callback(play_video, url=url) 141 | 142 | # Return the listitem as a generator. 143 | yield item 144 | 145 | # Extract the next page url if one exists. 146 | next_tag = root_elem.find("./div[@class='pagination pagination-centered']/ul") 147 | if next_tag is not None: 148 | # Find all page links 149 | next_tag = next_tag.findall("li/a") 150 | # Reverse list of links so the next page link should be the first item 151 | next_tag.reverse() 152 | # Attempt to find the next page link with the text of '>>' 153 | for node in next_tag: 154 | if node.text == u"\xbb": 155 | yield Listitem.next_page(url=node.get("href"), callback=video_list) 156 | # We found the link so we can now break from the loop 157 | break 158 | 159 | Finally we need to create the :class:`Resolver` "callback", and register it as so. 160 | This callback is expected to return a playable video URL. The first argument that will be passed to a 161 | :class:`Resolver` callback, will always be a 162 | :class:`Resolver` instance. 163 | 164 | .. code-block:: python 165 | 166 | @Resolver.register 167 | def play_video(plugin, url): 168 | # Sence https://metalvideo.com uses enbeaded youtube videos, 169 | # we can use 'plugin.extract_source' to extract the video url. 170 | url = url_constructor(url) 171 | return plugin.extract_source(url) 172 | 173 | :func:`plugin.extract_source` uses "YouTube.DL" to extract the 174 | video URL. Since it uses YouTube.DL, it will work with way-more than just youtube. 175 | 176 | .. seealso:: https://rg3.github.io/youtube-dl/supportedsites.html 177 | 178 | So to finish, we need to initiate the "codequick" startup process. 179 | This will call the "callback functions" automatically for you. 180 | 181 | .. code-block:: python 182 | 183 | if __name__ == "__main__": 184 | run() 185 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | # Standard Library Imports 5 | import sys 6 | import re 7 | 8 | # Kodi imports 9 | import xbmc 10 | 11 | try: 12 | import urllib.parse as urlparse 13 | except ImportError: 14 | # noinspection PyUnresolvedReferences 15 | import urlparse 16 | 17 | PY3 = sys.version_info[0] >= 3 18 | 19 | # Unicode Type object, unicode on python2 or str on python3 20 | unicode_type = type(u"") 21 | 22 | string_map = {} 23 | """ 24 | Dict of localized string references used in conjunction with 25 | :class:`Script.localize`. 26 | Allowing you to use the string as the localized string reference. 27 | 28 | .. note:: It is best if you set the string references at the top of your add-on python file. 29 | 30 | :example: 31 | >>> Script.localize(30001) 32 | "Toutes les vidéos" 33 | >>> 34 | >>> # Add reference id for "All Videos" so you can use the string name instead. 35 | >>> utils.string_map["All Videos": 30001] 36 | >>> Script.localize("All Videos") 37 | "Toutes les vidéos" 38 | """ 39 | 40 | 41 | def keyboard(heading, default="", hidden=False): 42 | """ 43 | Show a keyboard dialog. 44 | 45 | :param str heading: Keyboard heading. 46 | :param str default: [opt] Default text. 47 | :param bool hidden: [opt] ``True`` for hidden text entry. 48 | 49 | :return: Returns the user input as unicode. 50 | :rtype: str 51 | """ 52 | # Convert inputs to strings if required 53 | heading = ensure_native_str(heading) 54 | default = ensure_native_str(default) 55 | 56 | # Show the onscreen keyboard 57 | kb = xbmc.Keyboard(default, heading, hidden) 58 | kb.doModal() 59 | 60 | # Return user input only if 'OK' was pressed (confirmed) 61 | if kb.isConfirmed(): 62 | text = kb.getText() 63 | return text.decode("utf8") if isinstance(text, bytes) else text 64 | else: 65 | return u"" # pragma: no cover 66 | 67 | 68 | def parse_qs(qs, keep_blank_values=False, strict_parsing=False): 69 | """ 70 | Parse a "urlencoded" query string, and return the data as a dictionary. 71 | 72 | Parse a query string given as a string or unicode argument (data of type application/x-www-form- urlencoded). 73 | Data is returned as a dictionary. The dictionary keys are the "Unique" query variable names and 74 | the values are "Unicode" values for each name. 75 | 76 | The optional argument ``keep_blank_values``, is a flag indicating whether blank values in percent-encoded queries 77 | should be treated as a blank string. A ``True`` value indicates that blanks should be retained as a blank string. 78 | The default ``False`` value indicates that blank values are to be ignored and treated as if they were not included. 79 | 80 | The optional argument ``strict_parsing``, is a flag indicating what to do with parsing errors. If ``False`` 81 | (the default), errors are silently ignored. If ``True``, errors raise a "ValueError" exception. 82 | 83 | :param str qs: Percent-encoded "query string" to be parsed, or a URL with a "query string". 84 | :param bool keep_blank_values: ``True`` to keep blank values, else discard. 85 | :param bool strict_parsing: ``True`` to raise "ValueError" if there are parsing errors, else silently ignore. 86 | 87 | :return: Returns a dictionary of key/value pairs, with all keys and values as "Unicode". 88 | :rtype: dict 89 | 90 | :raises ValueError: If duplicate query field names exists or if there is a parsing error. 91 | 92 | :example: 93 | >>> parse_qs("http://example.com/path?q=search&safe=no") 94 | {u"q": u"search", u"safe": u"no"} 95 | >>> parse_qs(u"q=search&safe=no") 96 | {u"q": u"search", u"safe": u"no"} 97 | """ 98 | params = {} 99 | qs = ensure_native_str(qs) 100 | parsed = urlparse.parse_qsl(qs.split("?", 1)[-1], keep_blank_values, strict_parsing) 101 | if PY3: 102 | for key, value in parsed: 103 | if key not in params: 104 | params[key] = value 105 | else: 106 | # Only add keys that are not already added, multiple values are not supported 107 | raise ValueError("encountered duplicate param field name: '%s'" % key) 108 | else: 109 | for bkey, value in parsed: 110 | ukey = bkey.decode("utf8") 111 | if ukey not in params: 112 | params[ukey] = value.decode("utf8") 113 | else: 114 | # Only add keys that are not already added, multiple values are not supported 115 | raise ValueError("encountered duplicate param field name: '%s'" % bkey) 116 | 117 | # Return the params with all keys and values as unicode 118 | return params 119 | 120 | 121 | def urljoin_partial(base_url): 122 | """ 123 | Construct a full (absolute) URL by combining a base URL with another URL. 124 | 125 | This is useful when parsing HTML, as the majority of links would be relative links. 126 | 127 | Informally, this uses components of the base URL, in particular the addressing scheme, 128 | the network location and (part of) the path, to provide missing components in the relative URL. 129 | 130 | Returns a new "partial" object which when called, will pass ``base_url`` to :func:`urlparse.urljoin` along with the 131 | supplied relative URL. 132 | 133 | :param str base_url: The absolute URL to use as the base. 134 | :returns: A partial function that accepts a relative URL and returns a full absolute URL. 135 | 136 | :example: 137 | >>> url_constructor = urljoin_partial("https://google.ie/") 138 | >>> url_constructor("/path/to/something") 139 | "https://google.ie/path/to/something" 140 | >>> url_constructor("/gmail") 141 | "https://google.ie/gmail" 142 | """ 143 | base_url = ensure_unicode(base_url) 144 | 145 | def wrapper(url): 146 | """ 147 | Construct a full (absolute) using saved base url. 148 | 149 | :param str url: The relative URL to combine with base. 150 | :return: Absolute url. 151 | :rtype: str 152 | """ 153 | return urlparse.urljoin(base_url, ensure_unicode(url)) 154 | 155 | return wrapper 156 | 157 | 158 | def strip_tags(html): 159 | """ 160 | Strips out HTML tags and return plain text. 161 | 162 | :param str html: HTML with text to extract. 163 | :returns: Html with tags striped out 164 | :rtype: str 165 | 166 | :example: 167 | >>> strip_tags('I linked to example.com') 168 | "I linked to example.com" 169 | """ 170 | # This will fail under python3 when html is of type bytes 171 | # This is ok sence you will have much bigger problems if you are still using bytes on python3 172 | return re.sub("<[^<]+?>", "", html) 173 | 174 | 175 | def ensure_native_str(data, encoding="utf8"): 176 | """ 177 | Ensures that the given string is returned as a native str type, ``bytes`` on Python 2, ``unicode`` on Python 3. 178 | 179 | :param data: String to convert if needed. 180 | :param str encoding: [opt] The encoding to use if needed.. 181 | :returns: The given string as a native ``str`` type. 182 | :rtype: str 183 | """ 184 | # This is the fastest way 185 | # that I can find to do this 186 | if isinstance(data, str): 187 | return data 188 | elif isinstance(data, unicode_type): 189 | # Only executes on python 2 190 | return data.encode(encoding) 191 | elif isinstance(data, bytes): 192 | # Only executes on python 3 193 | return data.decode(encoding) 194 | else: 195 | return str(data) 196 | 197 | 198 | def ensure_unicode(data, encoding="utf8"): 199 | """ 200 | Ensures that the given string is return as type ``unicode``. 201 | 202 | :type data: str or bytes 203 | :param data: String to convert if needed. 204 | :param str encoding: [opt] The encoding to use if needed.. 205 | 206 | :returns: The given string as type ``unicode``. 207 | :rtype: str 208 | """ 209 | return data.decode(encoding) if isinstance(data, bytes) else unicode_type(data) 210 | 211 | 212 | def bold(text): 213 | """ 214 | Return Bolded text. 215 | 216 | :param str text: Text to bold. 217 | :returns: Bolded text. 218 | :rtype: str 219 | """ 220 | return "[B]%s[/B]" % text 221 | 222 | 223 | def italic(text): 224 | """ 225 | Return Italic text. 226 | 227 | :param str text: Text to italic. 228 | :returns: Italic text. 229 | :rtype: str 230 | """ 231 | return "[I]%s[/I]" % text 232 | 233 | 234 | def color(text, color_code): 235 | """ 236 | Return Colorized text of givin color. 237 | 238 | :param str text: Text to italic. 239 | :param str color_code: Color to change text to. 240 | :returns: Colorized text. 241 | :rtype: str 242 | """ 243 | return "[COLOR %s]%s[/COLOR]" % (color_code, text) 244 | -------------------------------------------------------------------------------- /tests/test_resolver.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | 4 | from addondev.testing import plugin_data, mock_select_dialog 5 | from xbmcgui import ListItem as kodi_listitem 6 | import xbmc 7 | 8 | from codequick import resolver, localized 9 | from codequick.listing import Listitem as custom_listitem 10 | from codequick.support import dispatcher 11 | 12 | from . import YDStreamExtractor 13 | sys.modules["YDStreamExtractor"] = YDStreamExtractor 14 | 15 | 16 | def temp_callback(func): 17 | def wrapper(*args): 18 | # noinspection PyUnusedLocal 19 | @resolver.Resolver.register 20 | def root(_): 21 | pass 22 | 23 | try: 24 | func(*args) 25 | finally: 26 | del dispatcher.registered_routes[root.route.path] 27 | 28 | return wrapper 29 | 30 | 31 | class TestGlobalLocalization(unittest.TestCase): 32 | def test_select_playback_item(self): 33 | ret = xbmc.getLocalizedString(localized.SELECT_PLAYBACK_ITEM) 34 | self.assertEqual(ret, "Select playback item") 35 | 36 | def test_nodata(self): 37 | ret = xbmc.getLocalizedString(localized.NO_DATA) 38 | self.assertEqual(ret, "No data found!") 39 | 40 | 41 | class TestResolver(unittest.TestCase): 42 | def setUp(self): 43 | self.resolver = resolver.Resolver() 44 | 45 | def test_bytes(self): 46 | self.resolver._process_results(b"test.mkv") 47 | self.assertTrue(plugin_data["succeeded"]) 48 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 49 | 50 | def test_unicode(self): 51 | self.resolver._process_results(u"test.mkv") 52 | self.assertTrue(plugin_data["succeeded"]) 53 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 54 | 55 | def test_return_false(self): 56 | self.resolver._process_results(False) 57 | self.assertFalse(plugin_data["succeeded"]) 58 | 59 | def test_no_url(self): 60 | with self.assertRaises(RuntimeError): 61 | self.resolver._process_results(None) 62 | 63 | def test_invalid_url(self): 64 | with self.assertRaises(ValueError): 65 | self.resolver._process_results(9) 66 | 67 | def test_kodi_listitem(self): 68 | item = kodi_listitem() 69 | item.setLabel("test") 70 | item.setPath(u"test.mkv") 71 | 72 | self.resolver._process_results(item) 73 | self.assertTrue(plugin_data["succeeded"]) 74 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 75 | 76 | def test_custom_listitem(self): 77 | item = custom_listitem() 78 | item.label = "test" 79 | item.set_path(u"http://test.mkv") 80 | 81 | self.resolver._process_results(item) 82 | self.assertTrue(plugin_data["succeeded"]) 83 | self.assertEqual(plugin_data["resolved"]["path"], u"http://test.mkv") 84 | 85 | def test_list_single(self): 86 | del plugin_data["playlist"][:] 87 | 88 | self.resolver._process_results([u"test.mkv"]) 89 | self.assertTrue(plugin_data["succeeded"]) 90 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 91 | self.assertEqual(len(plugin_data["playlist"]), 0) 92 | 93 | def test_list(self): 94 | del plugin_data["playlist"][:] 95 | 96 | self.resolver._process_results([u"test.mkv", u"tester.mkv"]) 97 | self.assertTrue(plugin_data["succeeded"]) 98 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 99 | self.assertEqual(len(plugin_data["playlist"]), 1) 100 | 101 | def test_list_none(self): 102 | del plugin_data["playlist"][:] 103 | 104 | self.resolver._process_results([u"test.mkv", u"tester.mkv"]) 105 | self.assertTrue(plugin_data["succeeded"]) 106 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 107 | self.assertEqual(len(plugin_data["playlist"]), 1) 108 | 109 | def test_tuple_single(self): 110 | del plugin_data["playlist"][:] 111 | 112 | self.resolver._process_results((u"test.mkv",)) 113 | self.assertTrue(plugin_data["succeeded"]) 114 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 115 | self.assertEqual(len(plugin_data["playlist"]), 0) 116 | 117 | def test_tuple(self): 118 | del plugin_data["playlist"][:] 119 | 120 | self.resolver._process_results((u"test.mkv", u"tester.mkv")) 121 | self.assertTrue(plugin_data["succeeded"]) 122 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 123 | self.assertEqual(len(plugin_data["playlist"]), 1) 124 | 125 | def test_dict(self): 126 | del plugin_data["playlist"][:] 127 | 128 | self.resolver._process_results({"test": "test.mkv"}) 129 | self.assertTrue(plugin_data["succeeded"]) 130 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 131 | self.assertEqual(len(plugin_data["playlist"]), 0) 132 | 133 | def test_dict_muili(self): 134 | del plugin_data["playlist"][:] 135 | 136 | self.resolver._process_results({"test": "test.mkv", "work": "work.mkv"}) 137 | self.assertTrue(plugin_data["succeeded"]) 138 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 139 | self.assertEqual(len(plugin_data["playlist"]), 1) 140 | 141 | def test_gen_single(self): 142 | del plugin_data["playlist"][:] 143 | 144 | def eg_resolver(): 145 | yield "test_one.mkv" 146 | 147 | self.resolver._process_results(eg_resolver()) 148 | self.assertTrue(plugin_data["succeeded"]) 149 | self.assertEqual(plugin_data["resolved"]["path"], u"test_one.mkv") 150 | self.assertEqual(len(plugin_data["playlist"]), 0) 151 | 152 | def test_gen_multi(self): 153 | del plugin_data["playlist"][:] 154 | 155 | def eg_resolver(): 156 | yield "test_one.mkv" 157 | yield "test_two.mkv" 158 | 159 | self.resolver._process_results(eg_resolver()) 160 | dispatcher.run_delayed() 161 | self.assertTrue(plugin_data["succeeded"]) 162 | self.assertEqual(plugin_data["resolved"]["path"], u"test_one.mkv") 163 | self.assertEqual(1, len(plugin_data["playlist"])) 164 | 165 | def test_gen_multi_none(self): 166 | del plugin_data["playlist"][:] 167 | 168 | def eg_resolver(): 169 | yield "test_one.mkv" 170 | yield "test_two.mkv" 171 | 172 | self.resolver._process_results(eg_resolver()) 173 | dispatcher.run_delayed() 174 | self.assertTrue(plugin_data["succeeded"]) 175 | self.assertEqual(plugin_data["resolved"]["path"], u"test_one.mkv") 176 | self.assertEqual(1, len(plugin_data["playlist"])) 177 | 178 | def test_playlist_kodi_listitem(self): 179 | del plugin_data["playlist"][:] 180 | 181 | item = kodi_listitem() 182 | item.setLabel("test") 183 | item.setPath(u"test.mkv") 184 | 185 | self.resolver._process_results([item]) 186 | self.assertTrue(plugin_data["succeeded"]) 187 | self.assertEqual(plugin_data["resolved"]["path"], u"test.mkv") 188 | 189 | def test_playlist_custom_listitem(self): 190 | del plugin_data["playlist"][:] 191 | 192 | item = custom_listitem() 193 | item.label = "test" 194 | item.set_path(u"http://test.mkv") 195 | 196 | self.resolver._process_results([item]) 197 | self.assertTrue(plugin_data["succeeded"]) 198 | self.assertEqual(plugin_data["resolved"]["path"], u"http://test.mkv") 199 | 200 | @temp_callback 201 | def test_create_loopback(self): 202 | del plugin_data["playlist"][:] 203 | 204 | self.resolver.create_loopback("video.mkv") 205 | self.assertEqual(len(plugin_data["playlist"]), 2) 206 | 207 | @temp_callback 208 | def test_continue_loopback(self): 209 | del plugin_data["playlist"][:] 210 | self.resolver._title = "_loopback_ - tester" 211 | 212 | self.resolver.create_loopback("video.mkv") 213 | self.assertEqual(len(plugin_data["playlist"]), 1) 214 | 215 | def test_extract_source(self): 216 | YDStreamExtractor.mode = 0 # single 217 | ret = self.resolver.extract_source("url") 218 | self.assertEqual(ret, "video.mkv") 219 | 220 | def test_extract_novideo(self): 221 | YDStreamExtractor.mode = 2 # novideo 222 | ret = self.resolver.extract_source("url") 223 | self.assertIsNone(ret) 224 | 225 | def test_extract_sourcewith_params(self): 226 | YDStreamExtractor.mode = 0 # novideo 227 | ret = self.resolver.extract_source("url", novalidate=True) 228 | self.assertEqual(ret, "video.mkv") 229 | 230 | def test_extract_source_multiple(self): 231 | YDStreamExtractor.mode = 1 # multiple 232 | with mock_select_dialog(0): 233 | ret = self.resolver.extract_source("url") 234 | self.assertEqual(ret, "video.mkv") 235 | 236 | def test_extract_source_multiple_canceled(self): 237 | YDStreamExtractor.mode = 1 # multiple 238 | with mock_select_dialog(-1): 239 | ret = self.resolver.extract_source("url") 240 | self.assertIsNone(ret) 241 | 242 | def test_extract_source_error(self): 243 | YDStreamExtractor.mode = 3 # raise error 244 | with self.assertRaises(RuntimeError): 245 | self.resolver.extract_source("url") 246 | 247 | def test_extract_source_warning(self): 248 | YDStreamExtractor.mode = 4 # raise warning 249 | ret = self.resolver.extract_source("url") 250 | self.assertEqual(ret, "video.mkv") 251 | -------------------------------------------------------------------------------- /tests/test_route.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from addondev.testing import plugin_data, reset_plugin_data 3 | import xbmc 4 | 5 | from codequick.listing import Listitem 6 | from codequick.support import auto_sort, Route 7 | from codequick import route, localized 8 | 9 | import xbmcplugin 10 | SORT_DATE = xbmcplugin.SORT_METHOD_DATE 11 | SORT_TITLE = xbmcplugin.SORT_METHOD_TITLE_IGNORE_THE 12 | SORT_UNSORT = xbmcplugin.SORT_METHOD_UNSORTED 13 | SORT_GENRE = xbmcplugin.SORT_METHOD_GENRE 14 | SORT_YEAR = xbmcplugin.SORT_METHOD_VIDEO_YEAR 15 | 16 | 17 | @route.Route.register 18 | def callback_test(_): 19 | pass 20 | 21 | 22 | class TestGlobalLocalization(unittest.TestCase): 23 | def test_select_playback_item(self): 24 | ret = xbmc.getLocalizedString(localized.SELECT_PLAYBACK_ITEM) 25 | self.assertEqual(ret, "Select playback item") 26 | 27 | def test_nodata(self): 28 | ret = xbmc.getLocalizedString(localized.NO_DATA) 29 | self.assertEqual(ret, "No data found!") 30 | 31 | 32 | class TestRoute(unittest.TestCase): 33 | def setUp(self): 34 | reset_plugin_data() 35 | self.route = route.Route() 36 | 37 | def test_gen(self): 38 | def route_gen(_): 39 | yield Listitem.from_dict(callback_test, "test item") 40 | 41 | callback_route = Route(route_gen, route.Route, "", {}) 42 | self.route(callback_route, [], {}) 43 | self.assertTrue(plugin_data["succeeded"]) 44 | 45 | def test_list(self): 46 | def route_list(_): 47 | return [Listitem.from_dict(callback_test, "test item")] 48 | 49 | callback_route = Route(route_list, route.Route, "", {}) 50 | self.route(callback_route, [], {}) 51 | self.assertTrue(plugin_data["succeeded"]) 52 | 53 | def test_return_false(self): 54 | self.route._process_results(False) 55 | self.assertFalse(plugin_data["succeeded"]) 56 | 57 | def test_yield_false(self): 58 | def route_list(_): 59 | yield False 60 | 61 | callback_route = Route(route_list, route.Route, "", {}) 62 | self.route(callback_route, [], {}) 63 | self.assertFalse(plugin_data["succeeded"]) 64 | 65 | def test_no_items(self): 66 | with self.assertRaises(RuntimeError): 67 | self.route._process_results([]) 68 | 69 | def test_no_invalid(self): 70 | with self.assertRaises(ValueError): 71 | self.route._process_results(1) 72 | 73 | def test_one_mediatype(self): 74 | def route_list(_): 75 | yield Listitem.from_dict(callback_test, "test item", info={"mediatype": "video"}) 76 | 77 | callback_route = Route(route_list, route.Route, "", {}) 78 | self.route(callback_route, [], {}) 79 | self.assertTrue(plugin_data["succeeded"]) 80 | self.assertEqual(plugin_data["contenttype"], "videos") 81 | 82 | def test_two_mediatype(self): 83 | def route_list(_): 84 | yield Listitem.from_dict(callback_test, "test item one", info={"mediatype": "video"}) 85 | yield Listitem.from_dict(callback_test, "test item two", info={"mediatype": "movie"}) 86 | yield Listitem.from_dict(callback_test, "test item three", info={"mediatype": "video"}) 87 | 88 | callback_route = Route(route_list, route.Route, "", {}) 89 | self.route(callback_route, [], {}) 90 | self.assertTrue(plugin_data["succeeded"]) 91 | self.assertEqual(plugin_data["contenttype"], "videos") 92 | 93 | def test_unsupported_mediatype(self): 94 | def route_list(_): 95 | yield Listitem.from_dict(callback_test, "season one", info={"mediatype": "season"}) 96 | 97 | callback_route = Route(route_list, route.Route, "", {}) 98 | self.route(callback_route, [], {}) 99 | self.assertTrue(plugin_data["succeeded"]) 100 | self.assertEqual(plugin_data["contenttype"], "files") 101 | 102 | def test_unset_contenttype(self): 103 | def route_list(_): 104 | yield Listitem.from_dict(callback_test, "season one") 105 | 106 | callback_route = Route(route_list, route.Route, "", {}) 107 | self.route(callback_route, [], {}) 108 | self.assertTrue(plugin_data["succeeded"]) 109 | self.assertEqual(plugin_data["contenttype"], "files") 110 | 111 | def test_sortmethod(self): 112 | auto_sort.clear() 113 | del plugin_data["sortmethods"][:] 114 | 115 | def route_list(_): 116 | yield Listitem.from_dict("http://season one", "test.mkv") 117 | 118 | callback_route = Route(route_list, route.Route, "", {}) 119 | self.route(callback_route, [], {}) 120 | self.assertTrue(plugin_data["succeeded"]) 121 | self.assertListEqual(plugin_data["sortmethods"], [SORT_UNSORT, SORT_TITLE]) 122 | 123 | def test_sortmethod_date(self): 124 | auto_sort.clear() 125 | del plugin_data["sortmethods"][:] 126 | 127 | def route_list(_): 128 | item = Listitem.from_dict("http://season one", "test.mkv") 129 | item.info.date("june 27, 2017", "%B %d, %Y") 130 | yield item 131 | 132 | callback_route = Route(route_list, route.Route, "", {}) 133 | self.route(callback_route, [], {}) 134 | self.assertTrue(plugin_data["succeeded"]) 135 | self.assertListEqual(plugin_data["sortmethods"], [SORT_DATE, SORT_TITLE, SORT_YEAR]) 136 | 137 | def test_sortmethod_genre(self): 138 | auto_sort.clear() 139 | del plugin_data["sortmethods"][:] 140 | 141 | def route_list(_): 142 | yield Listitem.from_dict("http://season one", "test.mkv", info={"genre": "test"}) 143 | 144 | callback_route = Route(route_list, route.Route, "", {}) 145 | self.route(callback_route, [], {}) 146 | self.assertTrue(plugin_data["succeeded"]) 147 | self.assertListEqual(plugin_data["sortmethods"], [SORT_UNSORT, SORT_TITLE, SORT_GENRE]) 148 | 149 | def test_no_sort(self): 150 | auto_sort.clear() 151 | del plugin_data["sortmethods"][:] 152 | 153 | def route_list(plugin): 154 | plugin.autosort = False 155 | yield Listitem.from_dict("http://season one", "test.mkv") 156 | 157 | callback_route = Route(route_list, route.Route, "", {}) 158 | self.route(callback_route, [], {}) 159 | self.assertTrue(plugin_data["succeeded"]) 160 | self.assertListEqual(plugin_data["sortmethods"], [SORT_UNSORT]) 161 | 162 | def test_no_sort_genre(self): 163 | auto_sort.clear() 164 | del plugin_data["sortmethods"][:] 165 | 166 | def route_list(plugin): 167 | plugin.autosort = False 168 | yield Listitem.from_dict("http://season one", "test.mkv", info={"genre": "test"}) 169 | 170 | callback_route = Route(route_list, route.Route, "", {}) 171 | self.route(callback_route, [], {}) 172 | self.assertTrue(plugin_data["succeeded"]) 173 | self.assertListEqual(plugin_data["sortmethods"], [SORT_UNSORT]) 174 | 175 | def test_custom_sort_only(self): 176 | auto_sort.clear() 177 | del plugin_data["sortmethods"][:] 178 | 179 | def route_list(plugin): 180 | plugin.add_sort_methods(3, disable_autosort=True) 181 | yield Listitem.from_dict("http://seasonone.com/works", "test.mkv", info={"genre": "test"}) 182 | 183 | callback_route = Route(route_list, route.Route, "", {}) 184 | self.route(callback_route, [], {}) 185 | self.assertTrue(plugin_data["succeeded"]) 186 | self.assertListEqual(plugin_data["sortmethods"], [SORT_DATE]) 187 | 188 | def test_custom_sort_only_method_2(self): 189 | auto_sort.clear() 190 | del plugin_data["sortmethods"][:] 191 | 192 | def route_list(plugin): 193 | plugin.add_sort_methods(3, disable_autosort=True) 194 | yield Listitem.from_dict("http://season one", "test.mkv", info={"genre": "test"}) 195 | 196 | callback_route = Route(route_list, route.Route, "", {}) 197 | self.route(callback_route, [], {}) 198 | self.assertTrue(plugin_data["succeeded"]) 199 | self.assertListEqual(plugin_data["sortmethods"], [SORT_DATE]) 200 | 201 | def test_custom_sort_with_autosort(self): 202 | auto_sort.clear() 203 | del plugin_data["sortmethods"][:] 204 | 205 | def route_list(plugin): 206 | plugin.add_sort_methods(SORT_DATE) 207 | yield Listitem.from_dict("http://season one", "test.mkv", info={"genre": "test"}) 208 | 209 | callback_route = Route(route_list, route.Route, "", {}) 210 | self.route(callback_route, [], {}) 211 | self.assertTrue(plugin_data["succeeded"]) 212 | self.assertListEqual(plugin_data["sortmethods"], [SORT_DATE, SORT_TITLE, SORT_GENRE]) 213 | 214 | def test_custom_sort_override(self): 215 | auto_sort.clear() 216 | del plugin_data["sortmethods"][:] 217 | 218 | def route_list(plugin): 219 | plugin.add_sort_methods(SORT_DATE) 220 | yield Listitem.from_dict("http://season one", "test.mkv", info={"genre": "test"}) 221 | item = Listitem.from_dict("http://season one", "test.mkv") 222 | item.info.date("june 27, 2017", "%B %d, %Y") 223 | yield item 224 | 225 | callback_route = Route(route_list, route.Route, "", {}) 226 | self.route(callback_route, [], {}) 227 | self.assertTrue(plugin_data["succeeded"]) 228 | self.assertListEqual(plugin_data["sortmethods"], [SORT_DATE, SORT_TITLE, SORT_GENRE, SORT_YEAR]) 229 | 230 | def test_no_content(self): 231 | def route_list(_): 232 | yield Listitem.from_dict(callback_test, "test item") 233 | 234 | self.route.content_type = None 235 | callback_route = Route(route_list, route.Route, "", {}) 236 | self.route(callback_route, [], {}) 237 | self.assertTrue(plugin_data["succeeded"]) 238 | self.assertIsNone(plugin_data["contenttype"]) 239 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/route.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | # Standard Lib 5 | from collections import defaultdict 6 | from operator import itemgetter 7 | import logging 8 | import inspect 9 | import hashlib 10 | import sys 11 | import re 12 | 13 | # Kodi imports 14 | import xbmcplugin 15 | 16 | # Package imports 17 | from codequick.storage import Cache 18 | from codequick.script import Script 19 | from codequick.support import logger_id, auto_sort 20 | from codequick.utils import ensure_native_str 21 | 22 | __all__ = ["Route", "validate_listitems"] 23 | 24 | # Logger specific to this module 25 | logger = logging.getLogger("%s.route" % logger_id) 26 | 27 | 28 | def get_session_id(): 29 | url = sys.argv[0] + sys.argv[2] 30 | url = url.encode("utf8") if isinstance(url, type(u"")) else url 31 | return hashlib.sha1(url).hexdigest() 32 | 33 | 34 | def validate_listitems(raw_listitems): 35 | """Check if we have a vialid set of listitems.""" 36 | 37 | # Convert a generator of listitems into a list of listitems 38 | if inspect.isgenerator(raw_listitems): 39 | raw_listitems = list(raw_listitems) 40 | 41 | # Silently ignore False values 42 | elif raw_listitems is False: 43 | return False 44 | 45 | if raw_listitems: 46 | # Check that we have valid list of listitems 47 | if isinstance(raw_listitems, (list, tuple)): 48 | # Check for an explicite False return value 49 | return False if len(raw_listitems) == 1 and raw_listitems[0] is False else list(filter(None, raw_listitems)) 50 | else: 51 | raise ValueError("Unexpected return object: {}".format(type(raw_listitems))) 52 | else: 53 | raise RuntimeError("No items found") 54 | 55 | 56 | def guess_content_type(mediatypes): # type: (defaultdict) -> str 57 | """Guess the content type based on the mediatype set on the listitems.""" 58 | # See if we can guess the content_type based on the mediatypes from the listitem 59 | if len(mediatypes) > 1: 60 | # Sort mediatypes by there count, and return the highest count mediatype 61 | mediatype = sorted(mediatypes.items(), key=itemgetter(1))[-1][0] 62 | elif mediatypes: 63 | mediatype = mediatypes.popitem()[0] 64 | else: 65 | return "" 66 | 67 | # Convert mediatype to a content_type, not all mediatypes can be converted directly 68 | if mediatype in ("video", "movie", "tvshow", "episode", "musicvideo", "song", "album", "artist"): 69 | return mediatype + "s" 70 | 71 | 72 | def build_sortmethods(manualsort, autosort): # type: (list, set) -> list 73 | """Merge manual & auto sortmethod together.""" 74 | if autosort: 75 | # Add unsorted sort method if not sorted by date and no manually set sortmethods are given 76 | if not (manualsort or xbmcplugin.SORT_METHOD_DATE in autosort): 77 | manualsort.append(xbmcplugin.SORT_METHOD_UNSORTED) 78 | 79 | # Keep the order of the manually set sort methods 80 | # Only sort the auto sort methods 81 | for method in sorted(autosort): 82 | if method not in manualsort: 83 | manualsort.append(method) 84 | 85 | # If no sortmethods are given then set sort method to unsorted 86 | return manualsort if manualsort else [xbmcplugin.SORT_METHOD_UNSORTED] 87 | 88 | 89 | def send_to_kodi(handle, session): 90 | """Handle the processing of the listitems.""" 91 | # Guess the contenty type 92 | if session["content_type"] == -1: 93 | kodi_listitems = [] 94 | folder_counter = 0.0 95 | mediatypes = defaultdict(int) 96 | for listitem in session["listitems"]: 97 | # Build the kodi listitem 98 | listitem_tuple = listitem.build() 99 | kodi_listitems.append(listitem_tuple) 100 | 101 | # Track the mediatypes used 102 | if "mediatype" in listitem.info: 103 | mediatypes[listitem.info["mediatype"]] += 1 104 | 105 | # Track if listitem is a folder 106 | if listitem_tuple[2]: 107 | folder_counter += 1 108 | 109 | # Guess content type based on set mediatypes 110 | session["content_type"] = guess_content_type(mediatypes) 111 | 112 | if not session["content_type"]: # Fallback 113 | # Set content type based on type of content being listed 114 | isfolder = folder_counter > (len(kodi_listitems) / 2) 115 | session["content_type"] = "files" if isfolder else "videos" 116 | else: 117 | # Just build the kodi listitem without tracking anything 118 | kodi_listitems = [custom_listitem.build() for custom_listitem in session["listitems"]] 119 | 120 | # If redirect_single_item is set to True then redirect view to the first 121 | # listitem if it's the only listitem and that listitem is a folder 122 | if session["redirect"] and len(kodi_listitems) == 1 and kodi_listitems[0][2] is True: 123 | return kodi_listitems[0][0] # return the listitem path 124 | 125 | # Add sort methods 126 | for sortMethod in session["sortmethods"]: 127 | xbmcplugin.addSortMethod(handle, sortMethod) 128 | 129 | # Sets the category for skins to display 130 | if session["category"]: 131 | xbmcplugin.setPluginCategory(handle, ensure_native_str(session["category"])) 132 | 133 | # Sets the plugin category for skins to display 134 | if session["content_type"]: 135 | xbmcplugin.setContent(handle, ensure_native_str(session["content_type"])) 136 | 137 | success = xbmcplugin.addDirectoryItems(handle, kodi_listitems, len(kodi_listitems)) 138 | xbmcplugin.endOfDirectory(handle, success, session["update_listing"], session["cache_to_disc"]) 139 | 140 | 141 | class Route(Script): 142 | """ 143 | This class is used to create "Route" callbacks. “Route" callbacks, are callbacks that 144 | return "listitems" which will show up as folders in Kodi. 145 | 146 | Route inherits all methods and attributes from :class:`codequick.Script`. 147 | 148 | The possible return types from Route Callbacks are. 149 | * ``iterable``: "List" or "tuple", consisting of :class:`codequick.listitem` objects. 150 | * ``generator``: A Python "generator" that return's :class:`codequick.listitem` objects. 151 | * ``False``: This will cause the "plugin call" to quit silently, without raising a RuntimeError. 152 | 153 | :raises RuntimeError: If no content was returned from callback. 154 | 155 | :example: 156 | >>> from codequick import Route, Listitem 157 | >>> 158 | >>> @Route.register 159 | >>> def root(_): 160 | >>> yield Listitem.from_dict("Extra videos", subfolder) 161 | >>> yield Listitem.from_dict("Play video", "http://www.example.com/video1.mkv") 162 | >>> 163 | >>> @Route.register 164 | >>> def subfolder(_): 165 | >>> yield Listitem.from_dict("Play extra video", "http://www.example.com/video2.mkv") 166 | """ 167 | 168 | # Change listitem type to 'folder' 169 | is_folder = True 170 | 171 | def __init__(self): 172 | super(Route, self).__init__() 173 | self.update_listing = self.params.get(u"_updatelisting_", False) 174 | self.category = re.sub(r"\(\d+\)$", u"", self._title).strip() 175 | self.cache_to_disc = self.params.get(u"_cache_to_disc_", True) 176 | self.redirect_single_item = False 177 | self.sort_methods = list() 178 | self.content_type = -1 179 | self.autosort = True 180 | 181 | def __call__(self, route, args, kwargs): 182 | cache_ttl = getattr(self, "cache_ttl", -1) 183 | cache = Cache("listitem_cache.sqlite", cache_ttl * 60) if cache_ttl >= 0 else None 184 | session_id = get_session_id() 185 | 186 | # Check if this plugin path is cached and valid 187 | if cache and session_id in cache: 188 | logger.debug("Listitem Cache: Hit") 189 | session_data = cache[session_id] 190 | else: 191 | logger.debug("Listitem Cache: Miss") 192 | 193 | try: 194 | # Execute the callback 195 | results = super(Route, self).__call__(route, args, kwargs) 196 | session_data = self._process_results(results) 197 | if session_data and cache: 198 | cache[session_id] = session_data 199 | elif not session_data: 200 | return None 201 | finally: 202 | if cache: 203 | cache.close() 204 | 205 | # Send session data to kodi 206 | return send_to_kodi(self.handle, session_data) 207 | 208 | def _process_results(self, results): 209 | """Process the results and return a cacheable dict of session data.""" 210 | listitems = validate_listitems(results) 211 | if listitems is False: 212 | xbmcplugin.endOfDirectory(self.handle, False) 213 | return None 214 | 215 | return { 216 | "listitems": listitems, 217 | "category": ensure_native_str(self.category), 218 | "update_listing": self.update_listing, 219 | "cache_to_disc": self.cache_to_disc, 220 | "sortmethods": build_sortmethods(self.sort_methods, auto_sort if self.autosort else None), 221 | "content_type": self.content_type, 222 | "redirect": self.redirect_single_item 223 | } 224 | 225 | def add_sort_methods(self, *methods, **kwargs): 226 | """ 227 | Add sorting method(s). 228 | 229 | Any number of sort method's can be given as multiple positional arguments. 230 | Normally this should not be needed, as sort method's are auto detected. 231 | 232 | You can pass an optional keyword only argument, 'disable_autosort' to disable auto sorting. 233 | 234 | :param int methods: One or more Kodi sort method's. 235 | 236 | .. seealso:: The full list of sort methods can be found at.\n 237 | https://codedocs.xyz/xbmc/xbmc/group__python__xbmcplugin.html#ga85b3bff796fd644fb28f87b136025f40 238 | """ 239 | # Disable autosort if requested 240 | if kwargs.get("disable_autosort", False): 241 | self.autosort = False 242 | 243 | # Can't use sets here as sets don't keep order 244 | for method in methods: 245 | self.sort_methods.append(method) 246 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | from addondev import testing 2 | import unittest 3 | import xbmc 4 | import os 5 | 6 | # Testing specific imports 7 | from codequick import search, route, storage, localized 8 | from codequick.support import dispatcher 9 | from codequick.listing import Listitem 10 | 11 | # Link to search own hash params for testing 12 | hash_params = search.Search.hash_params 13 | 14 | 15 | class TestGlobalLocalization(unittest.TestCase): 16 | def test_enter_search_string(self): 17 | ret = xbmc.getLocalizedString(localized.ENTER_SEARCH_STRING) 18 | self.assertEqual(ret, "Enter search string") 19 | 20 | def test_remove(self): 21 | ret = xbmc.getLocalizedString(localized.REMOVE) 22 | self.assertEqual(ret, "Remove") 23 | 24 | def test_search(self): 25 | ret = xbmc.getLocalizedString(localized.SEARCH) 26 | self.assertEqual(ret, "Search") 27 | 28 | 29 | class Search(unittest.TestCase): 30 | def setUp(self): 31 | self.org_routes = dispatcher.registered_routes.copy() 32 | path = os.path.join(storage.profile_dir, search.SEARCH_DB) 33 | if os.path.exists(path): 34 | os.remove(path) 35 | 36 | def tearDown(self): 37 | dispatcher.reset() 38 | dispatcher.registered_routes.clear() 39 | dispatcher.registered_routes.update(self.org_routes) 40 | 41 | def test_first_load(self): 42 | @route.Route.register 43 | def results(_, search_query): 44 | self.assertEqual(search_query, "Rock") 45 | yield Listitem.from_dict(results, "listitem one") 46 | yield Listitem.from_dict(results, "listitem two") 47 | 48 | params = dict(_route=results.route.path) 49 | session_id = hash_params(params) 50 | 51 | with testing.mock_keyboard("Rock"): 52 | listitems = search.saved_searches.test(first_load=True, execute_delayed=True, **params) 53 | 54 | with storage.PersistentDict(search.SEARCH_DB) as db: 55 | self.assertIn(session_id, db) 56 | self.assertIn("Rock", db[session_id]) 57 | 58 | self.assertEqual(len(listitems), 2) 59 | self.assertEqual(listitems[0].label, "listitem one") 60 | self.assertEqual(listitems[1].label, "listitem two") 61 | 62 | def test_first_load_invalid(self): 63 | @route.Route.register 64 | def results(_, search_query): 65 | self.assertEqual(search_query, "Rock") 66 | return False 67 | 68 | params = dict(_route=results.route.path) 69 | session_id = hash_params(params) 70 | 71 | with testing.mock_keyboard("Rock"): 72 | listitems = search.saved_searches.test(first_load=True, execute_delayed=True, **params) 73 | 74 | with storage.PersistentDict(search.SEARCH_DB) as db: 75 | self.assertIn(session_id, db) 76 | self.assertNotIn("Rock", db[session_id]) 77 | 78 | self.assertFalse(listitems) 79 | 80 | def test_first_load_canceled(self): 81 | # noinspection PyUnusedLocal 82 | @route.Route.register 83 | def results(_, search_query): 84 | pass 85 | 86 | params = dict(_route=results.route.path) 87 | session_id = hash_params(params) 88 | 89 | with testing.mock_keyboard(""): 90 | listitems = search.saved_searches.test(first_load=True, execute_delayed=True, **params) 91 | 92 | with storage.PersistentDict(search.SEARCH_DB) as db: 93 | self.assertIn(session_id, db) 94 | self.assertFalse(bool(db[session_id])) 95 | 96 | self.assertFalse(listitems) 97 | 98 | def test_search_empty(self): 99 | @route.Route.register 100 | def results(_, search_query): 101 | self.assertEqual(search_query, "Rock") 102 | yield Listitem.from_dict(results, "listitem one") 103 | yield Listitem.from_dict(results, "listitem two") 104 | 105 | params = dict(_route=results.route.path) 106 | session_id = hash_params(params) 107 | 108 | with testing.mock_keyboard("Rock"): 109 | listitems = search.saved_searches.test(search=True, execute_delayed=True, **params) 110 | 111 | with storage.PersistentDict(search.SEARCH_DB) as db: 112 | self.assertIn(session_id, db) 113 | self.assertIn("Rock", db[session_id]) 114 | 115 | self.assertEqual(len(listitems), 2) 116 | self.assertEqual(listitems[0].label, "listitem one") 117 | self.assertEqual(listitems[1].label, "listitem two") 118 | 119 | def test_search_populated(self): 120 | @route.Route.register 121 | def results(_, search_query): 122 | self.assertEqual(search_query, "Rock") 123 | yield Listitem.from_dict(results, "listitem one") 124 | yield Listitem.from_dict(results, "listitem two") 125 | 126 | params = dict(_route=results.route.path) 127 | session_id = hash_params(params) 128 | 129 | with storage.PersistentDict(search.SEARCH_DB) as db: 130 | dbstore = db.setdefault(session_id, []) 131 | dbstore.append("Pop") 132 | db.flush() 133 | 134 | with testing.mock_keyboard("Rock"): 135 | listitems = search.saved_searches.test(search=True, execute_delayed=True, **params) 136 | 137 | with storage.PersistentDict(search.SEARCH_DB) as db: 138 | self.assertIn(session_id, db) 139 | self.assertIn("Rock", db[session_id]) 140 | self.assertIn("Pop", db[session_id]) 141 | 142 | self.assertEqual(len(listitems), 2) 143 | self.assertEqual(listitems[0].label, "listitem one") 144 | self.assertEqual(listitems[1].label, "listitem two") 145 | 146 | def test_search_populated_invalid(self): 147 | # noinspection PyUnusedLocal 148 | @route.Route.register 149 | def results(_, search_query): 150 | pass 151 | 152 | params = dict(_route=results.route.path) 153 | session_id = hash_params(params) 154 | 155 | with storage.PersistentDict(search.SEARCH_DB) as db: 156 | dbstore = db.setdefault(session_id, []) 157 | dbstore.append("Pop") 158 | db.flush() 159 | 160 | with testing.mock_keyboard(""): 161 | listitems = search.saved_searches.test(search=True, execute_delayed=True, **params) 162 | 163 | with storage.PersistentDict(search.SEARCH_DB) as db: 164 | self.assertIn(session_id, db) 165 | self.assertIn("Pop", db[session_id]) 166 | 167 | self.assertEqual(len(listitems), 2) 168 | self.assertIn("Search", listitems[0].label) 169 | self.assertEqual(listitems[1].label, "Pop") 170 | 171 | def test_saved_firstload(self): 172 | # noinspection PyUnusedLocal 173 | @route.Route.register 174 | def results(_, search_query): 175 | pass 176 | 177 | params = dict(_route=results.route.path) 178 | session_id = hash_params(params) 179 | 180 | with storage.PersistentDict(search.SEARCH_DB) as db: 181 | dbstore = db.setdefault(session_id, []) 182 | dbstore.append("Rock") 183 | dbstore.append("Pop") 184 | db.flush() 185 | 186 | listitems = search.saved_searches.test(first_load=True, execute_delayed=True, **params) 187 | 188 | with storage.PersistentDict(search.SEARCH_DB) as db: 189 | self.assertIn(session_id, db) 190 | self.assertIn("Rock", db[session_id]) 191 | self.assertIn("Pop", db[session_id]) 192 | 193 | self.assertEqual(len(listitems), 3) 194 | self.assertIn("Search", listitems[0].label) 195 | self.assertEqual(listitems[1].label, "Rock") 196 | self.assertEqual(listitems[2].label, "Pop") 197 | 198 | def test_saved_sessions(self): 199 | # noinspection PyUnusedLocal 200 | @route.Route.register 201 | def session_one(_, search_query): 202 | self.assertEqual(search_query, "Rock") 203 | yield Listitem.from_dict(session_one, "listitem one") 204 | yield Listitem.from_dict(session_one, "listitem two") 205 | 206 | # noinspection PyUnusedLocal 207 | @route.Route.register 208 | def session_two(_, search_query): 209 | self.assertEqual(search_query, "Pop") 210 | yield Listitem.from_dict(session_two, "listitem one") 211 | yield Listitem.from_dict(session_two, "listitem two") 212 | 213 | session_one_params = dict(_route=session_one.route.path) 214 | session_one_id = hash_params(session_one_params) 215 | 216 | session_two_params = dict(_route=session_two.route.path) 217 | session_two_id = hash_params(session_two_params) 218 | 219 | with storage.PersistentDict(search.SEARCH_DB) as db: 220 | dbstore = db.setdefault(session_one_id, []) 221 | dbstore.append("Jazz") 222 | dbstore = db.setdefault(session_two_id, []) 223 | dbstore.append("Chill") 224 | db.flush() 225 | 226 | with testing.mock_keyboard("Rock"): 227 | search.saved_searches.test(search=True, execute_delayed=True, **session_one_params) 228 | 229 | with testing.mock_keyboard("Pop"): 230 | search.saved_searches.test(search=True, execute_delayed=True, **session_two_params) 231 | 232 | with storage.PersistentDict(search.SEARCH_DB) as db: 233 | self.assertIn(session_one_id, db) 234 | self.assertIn("Rock", db[session_one_id]) 235 | self.assertNotIn("Pop", db[session_one_id]) 236 | 237 | self.assertIn(session_two_id, db) 238 | self.assertIn("Pop", db[session_two_id]) 239 | self.assertNotIn("Rock", db[session_two_id]) 240 | 241 | def test_saved_not_firstload(self): 242 | # noinspection PyUnusedLocal 243 | @route.Route.register 244 | def results(_, search_query): 245 | pass 246 | 247 | params = dict(_route=results.route.path) 248 | session_id = hash_params(params) 249 | 250 | with storage.PersistentDict(search.SEARCH_DB) as db: 251 | dbstore = db.setdefault(session_id, []) 252 | dbstore.append("Rock") 253 | dbstore.append("Pop") 254 | db.flush() 255 | 256 | with testing.mock_keyboard("Rock"): 257 | listitems = search.saved_searches.test(execute_delayed=True, **params) 258 | 259 | with storage.PersistentDict(search.SEARCH_DB) as db: 260 | self.assertIn(session_id, db) 261 | self.assertIn("Rock", db[session_id]) 262 | self.assertIn("Pop", db[session_id]) 263 | 264 | self.assertEqual(len(listitems), 3) 265 | self.assertIn("Search", listitems[0].label) 266 | self.assertEqual(listitems[1].label, "Rock") 267 | self.assertEqual(listitems[2].label, "Pop") 268 | 269 | def test_saved_remove(self): 270 | # noinspection PyUnusedLocal 271 | @route.Route.register 272 | def results(_, search_query): 273 | pass 274 | 275 | params = dict(_route=results.route.path) 276 | session_id = hash_params(params) 277 | 278 | with storage.PersistentDict(search.SEARCH_DB) as db: 279 | dbstore = db.setdefault(session_id, []) 280 | dbstore.append("Rock") 281 | dbstore.append("Pop") 282 | db.flush() 283 | 284 | listitems = search.saved_searches.test(remove_entry="Rock", execute_delayed=True, **params) 285 | 286 | with storage.PersistentDict(search.SEARCH_DB) as db: 287 | self.assertIn(session_id, db) 288 | self.assertNotIn("Rock", db[session_id]) 289 | self.assertIn("Pop", db[session_id]) 290 | 291 | self.assertEqual(len(listitems), 2) 292 | self.assertIn("Search", listitems[0].label) 293 | self.assertEqual(listitems[1].label, "Pop") 294 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/storage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | # Standard Library Imports 5 | from hashlib import sha1 6 | import sqlite3 7 | import time 8 | import os 9 | 10 | try: 11 | # noinspection PyPep8Naming 12 | import cPickle as pickle 13 | except ImportError: # pragma: no cover 14 | import pickle 15 | 16 | # Package imports 17 | from codequick.script import Script 18 | from codequick.utils import ensure_unicode, PY3 19 | 20 | if PY3: 21 | # noinspection PyUnresolvedReferences, PyCompatibility 22 | from collections.abc import MutableMapping, MutableSequence 23 | buffer = bytes 24 | else: 25 | # noinspection PyUnresolvedReferences, PyCompatibility 26 | from collections import MutableMapping, MutableSequence 27 | 28 | __all__ = ["PersistentDict", "PersistentList", "Cache"] 29 | 30 | # The addon profile directory 31 | profile_dir = Script.get_info("profile") 32 | 33 | 34 | def check_filename(name): 35 | # Filename is already a fullpath 36 | if os.path.sep in name: 37 | filepath = ensure_unicode(name) 38 | data_dir = os.path.dirname(filepath) 39 | else: 40 | # Filename must be relative, joining profile directory with filename 41 | filepath = os.path.join(profile_dir, ensure_unicode(name)) 42 | data_dir = profile_dir 43 | 44 | # Create any missing data directory 45 | if not os.path.exists(data_dir): 46 | os.makedirs(data_dir) 47 | 48 | # The full file path 49 | return filepath 50 | 51 | 52 | class _PersistentBase(object): 53 | """ 54 | Base class to handle persistent file handling. 55 | 56 | :param str name: Filename of persistence storage file. 57 | """ 58 | 59 | def __init__(self, name): 60 | super(_PersistentBase, self).__init__() 61 | self._filepath = check_filename(name) 62 | self._version_string = "__codequick_storage_version__" 63 | self._data_string = "__codequick_storage_data__" 64 | self._serializer_obj = object 65 | self._stream = None 66 | self._hash = None 67 | self._data = None 68 | 69 | def _load(self): 70 | """Load in existing data from disk.""" 71 | # Load storage file if exists 72 | if os.path.exists(self._filepath): 73 | self._stream = file_obj = open(self._filepath, "rb+") 74 | content = file_obj.read() 75 | 76 | # Calculate hash of current file content 77 | self._hash = sha1(content).hexdigest() 78 | 79 | # Load content and update storage 80 | return pickle.loads(content) 81 | 82 | def flush(self): 83 | """ 84 | Synchronize data back to disk. 85 | 86 | Data will only be written to disk if content has changed. 87 | """ 88 | 89 | # Serialize the storage data 90 | data = {self._version_string: 2, self._data_string: self._data} 91 | content = pickle.dumps(data, protocol=2) # Protocol 2 is used for python2/3 compatibility 92 | current_hash = sha1(content).hexdigest() 93 | 94 | # Compare saved hash with current hash, to detect if content has changed 95 | if self._hash is None or self._hash != current_hash: 96 | # Check if FileObj Needs Creating First 97 | if self._stream: 98 | self._stream.seek(0) 99 | self._stream.truncate(0) 100 | else: 101 | self._stream = open(self._filepath, "wb+") 102 | 103 | # Dump data out to disk 104 | self._stream.write(content) 105 | self._hash = current_hash 106 | self._stream.flush() 107 | 108 | def close(self): 109 | """Flush content to disk & close file object.""" 110 | self.flush() 111 | self._stream.close() 112 | self._stream = None 113 | 114 | def __enter__(self): 115 | return self 116 | 117 | def __exit__(self, *_): 118 | self.close() 119 | 120 | def __len__(self): 121 | return len(self._data) 122 | 123 | def __getitem__(self, index): 124 | return self._data[index][0] 125 | 126 | def __setitem__(self, index, value): 127 | self._data[index] = (value, time.time()) 128 | 129 | def __delitem__(self, index): 130 | del self._data[index] 131 | 132 | def __bool__(self): 133 | return bool(self._data) 134 | 135 | def __nonzero__(self): 136 | return bool(self._data) 137 | 138 | 139 | class PersistentDict(_PersistentBase, MutableMapping): 140 | """ 141 | Persistent storage with a :class:`dictionary` like interface. 142 | 143 | :param str name: Filename or path to storage file. 144 | :param int ttl: [opt] The amount of time in "seconds" that a value can be stored before it expires. 145 | 146 | .. note:: 147 | 148 | ``name`` can be a filename, or the full path to a file. 149 | The add-on profile directory will be the default location for files, unless a full path is given. 150 | 151 | .. note:: If the ``ttl`` parameter is given, "any" expired data will be removed on initialization. 152 | 153 | .. note:: This class is also designed as a "Context Manager". 154 | 155 | .. note:: 156 | 157 | Data will only be synced to disk when connection to file is 158 | "closed" or when "flush" method is explicitly called. 159 | 160 | :Example: 161 | >>> with PersistentDict("dictfile.pickle") as db: 162 | >>> db["testdata"] = "testvalue" 163 | >>> db.flush() 164 | """ 165 | 166 | def __iter__(self): 167 | return iter(self._data) 168 | 169 | def __repr__(self): 170 | return '%s(%r)' % (self.__class__.__name__, dict(self.items())) 171 | 172 | def __init__(self, name, ttl=None): 173 | super(PersistentDict, self).__init__(name) 174 | data = self._load() 175 | self._data = {} 176 | 177 | if data: 178 | version = data.get(self._version_string, 1) 179 | if version == 1: 180 | self._data = {key: (val, time.time()) for key, val in data.items()} 181 | else: 182 | data = data[self._data_string] 183 | if ttl: 184 | self._data = {key: item for key, item in data.items() if time.time() - item[1] < ttl} 185 | else: 186 | self._data = data 187 | 188 | def items(self): 189 | return map(lambda x: (x[0], x[1][0]), self._data.items()) 190 | 191 | 192 | class PersistentList(_PersistentBase, MutableSequence): 193 | """ 194 | Persistent storage with a :class:`list` like interface. 195 | 196 | :param str name: Filename or path to storage file. 197 | :param int ttl: [opt] The amount of time in "seconds" that a value can be stored before it expires. 198 | 199 | .. note:: 200 | 201 | ``name`` can be a filename, or the full path to a file. 202 | The add-on profile directory will be the default location for files, unless a full path is given. 203 | 204 | .. note:: If the ``ttl`` parameter is given, "any" expired data will be removed on initialization. 205 | 206 | .. note:: This class is also designed as a "Context Manager". 207 | 208 | .. note:: 209 | 210 | Data will only be synced to disk when connection to file is 211 | "closed" or when "flush" method is explicitly called. 212 | 213 | :Example: 214 | >>> with PersistentList("listfile.pickle") as db: 215 | >>> db.append("testvalue") 216 | >>> db.extend(["test1", "test2"]) 217 | >>> db.flush() 218 | """ 219 | 220 | def __repr__(self): 221 | return '%s(%r)' % (self.__class__.__name__, [val for val, _ in self._data]) 222 | 223 | def __init__(self, name, ttl=None): 224 | super(PersistentList, self).__init__(name) 225 | data = self._load() 226 | self._data = [] 227 | 228 | if data: 229 | if isinstance(data, list): 230 | self._data = [(val, time.time()) for val in data] 231 | else: 232 | data = data[self._data_string] 233 | if ttl: 234 | self._data = [item for item in data if time.time() - item[1] < ttl] 235 | else: 236 | self._data = data 237 | 238 | def insert(self, index, value): 239 | self._data.insert(index, (value, time.time())) 240 | 241 | def append(self, value): 242 | self._data.append((value, time.time())) 243 | 244 | 245 | class Cache(object): 246 | """ 247 | Handle control of listitem cache. 248 | 249 | :param str name: Filename or path to storage file. 250 | :param int ttl: [opt] The amount of time in "seconds" that a cached session can be stored before it expires. 251 | 252 | .. note:: Any expired cache item will be removed on first access to that item. 253 | """ 254 | def __init__(self, name, ttl): 255 | self.filepath = check_filename(name) 256 | self.buffer = {} 257 | self.ttl = ttl 258 | self._connect() 259 | 260 | def _connect(self): 261 | """Connect to sqlite cache database""" 262 | self.db = db = sqlite3.connect(self.filepath, timeout=3) 263 | self.cur = cur = db.cursor() 264 | db.isolation_level = None 265 | 266 | # Create cache table 267 | cur.execute("CREATE TABLE IF NOT EXISTS itemcache (key TEXT PRIMARY KEY, value BLOB, timestamp INTEGER)") 268 | db.commit() 269 | 270 | def execute(self, sqlquery, args, repeat=False): # type: (str, tuple, bool) -> None 271 | self.cur.execute("BEGIN") 272 | try: 273 | self.cur.execute(sqlquery, args) 274 | 275 | # Handle database errors 276 | except sqlite3.DatabaseError as e: 277 | # Check if database is currupted 278 | if not repeat and os.path.exists(self.filepath) and \ 279 | (str(e).find("file is encrypted") > -1 or str(e).find("not a database") > -1): 280 | Script.log("Deleting broken database file: %s", (self.filepath,), lvl=Script.DEBUG) 281 | self.close() 282 | os.remove(self.filepath) 283 | self._connect() 284 | self.execute(sqlquery, args, repeat=True) 285 | else: 286 | raise e 287 | 288 | # Just roll back database on error and raise again 289 | except Exception as e: 290 | self.db.rollback() 291 | raise e 292 | else: 293 | self.db.commit() 294 | 295 | def __getitem__(self, key): 296 | if key in self.buffer: 297 | return self.buffer[key] 298 | else: 299 | item = self.cur.execute("SELECT value, timestamp FROM itemcache WHERE key = ?", (key,)).fetchone() 300 | if item is None: 301 | raise KeyError(key) 302 | else: 303 | value, timestamp = item 304 | if self.ttl > -1 and timestamp + self.ttl < time.time(): # Expired 305 | del self[key] 306 | raise KeyError(key) 307 | else: 308 | return pickle.loads(bytes(value)) 309 | 310 | def __setitem__(self, key, value): 311 | data = buffer(pickle.dumps(value)) 312 | self.execute("REPLACE INTO itemcache (key, value, timestamp) VALUES (?,?,?)", (key, data, time.time())) 313 | 314 | def __delitem__(self, key): 315 | self.execute("DELETE FROM itemcache WHERE key = ?", (key,)) 316 | 317 | def __contains__(self, key): 318 | try: 319 | if key in self.buffer: 320 | return True 321 | else: 322 | self.buffer[key] = self[key] 323 | return True 324 | except KeyError: 325 | return False 326 | 327 | def __enter__(self): 328 | return self 329 | 330 | def __exit__(self, *_): 331 | self.close() 332 | 333 | def close(self): 334 | self.db.close() 335 | -------------------------------------------------------------------------------- /tests/test_support.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | import unittest 3 | import logging 4 | import inspect 5 | import sys 6 | 7 | # Testing specific imports 8 | from codequick import support, route, script 9 | 10 | PY3 = sys.version_info >= (3, 0) 11 | 12 | 13 | @contextmanager 14 | def mock_argv(argv): 15 | org_sys = sys.argv[:] 16 | sys.argv = argv 17 | try: 18 | yield 19 | finally: 20 | sys.argv = org_sys 21 | 22 | 23 | class TestLogging(unittest.TestCase): 24 | def test_logger(self): 25 | support.base_logger.debug("test debug") 26 | self.assertIn("[root] test debug", support.kodi_logger.debug_msgs) 27 | 28 | # noinspection PyMethodMayBeStatic 29 | def test_critical(self): 30 | logger = logging.getLogger() 31 | logger.disabled = False 32 | 33 | try: 34 | support.base_logger.info("info") 35 | support.base_logger.debug("debug") 36 | support.base_logger.critical("crash") 37 | finally: 38 | logger.disabled = False 39 | 40 | 41 | class TestRoute(unittest.TestCase): 42 | def setUp(self): 43 | # noinspection PyUnusedLocal 44 | def test_callback(_, one=1, two="2", return_data=None): 45 | return return_data 46 | 47 | path = test_callback.__name__.lower() 48 | self.route = support.Route(test_callback, route.Route, path, {}) 49 | 50 | def test_unittest_caller(self): 51 | ret = self.route.unittest_caller("one", two="two", return_data=True) 52 | self.assertTrue(ret) 53 | 54 | def test_unittest_caller_list(self): 55 | ret = self.route.unittest_caller("one", two="two", return_data=["data"]) 56 | self.assertListEqual(ret, ["data"]) 57 | 58 | def test_unittest_caller_no_args(self): 59 | ret = self.route.unittest_caller() 60 | self.assertIsNone(ret, ["data"]) 61 | 62 | def test_unittest_caller_error(self): 63 | def test_callback(_): 64 | raise RuntimeError 65 | 66 | path = test_callback.__name__.lower() 67 | route_obj = support.Route(test_callback, route.Route, path, {}) 68 | 69 | with self.assertRaises(RuntimeError): 70 | route_obj.unittest_caller() 71 | 72 | 73 | class TestDispatcher(unittest.TestCase): 74 | def setUp(self): 75 | self.dispatcher = support.Dispatcher() 76 | 77 | def test_reset(self): 78 | self.dispatcher.selector = "test" 79 | self.dispatcher.params["tester"] = True 80 | self.dispatcher.registered_delayed.append("test") 81 | 82 | self.dispatcher.reset() 83 | self.assertEqual(self.dispatcher.selector, "root") 84 | self.assertListEqual(self.dispatcher.registered_delayed, []) 85 | self.assertDictEqual(self.dispatcher.params, dict()) 86 | 87 | def test_parse_sysargs(self): 88 | dispatcher = support.Dispatcher() 89 | with mock_argv(["plugin://script.module.codequick/test/tester", 96, ""]): 90 | dispatcher.parse_args() 91 | 92 | self.assertEqual(dispatcher.selector, "/test/tester") 93 | 94 | def test_parse_sysargs_with_args(self): 95 | dispatcher = support.Dispatcher() 96 | with mock_argv(["plugin://script.module.codequick/test/tester", 96, 97 | "?testdata=true&worker=false&_title_=test"]): 98 | dispatcher.parse_args() 99 | 100 | self.assertEqual(dispatcher.selector, "/test/tester") 101 | self.assertTrue(dispatcher.params.get("testdata") == "true") 102 | self.assertTrue(dispatcher.params.get("worker") == "false") 103 | self.assertTrue(dispatcher.params.get("_title_") == "test") 104 | self.assertTrue(dispatcher.callback_params.get("testdata") == "true") 105 | self.assertTrue(dispatcher.callback_params.get("worker") == "false") 106 | 107 | @unittest.skipIf(PY3, "The pickled string is specific to python 2") 108 | def test_parse_params_pickle_py2(self): 109 | dispatcher = support.Dispatcher() 110 | with mock_argv(["plugin://script.module.codequick/test/tester", 96, 111 | "?_pickle_=80027d7100285506776f726b65727101895508746573746461746171028855075f7469746c655f710355" 112 | "04746573747104752e"]): 113 | dispatcher.parse_args() 114 | 115 | self.assertEqual(dispatcher.selector, "/test/tester") 116 | self.assertTrue(dispatcher.params.get("testdata") is True) 117 | self.assertTrue(dispatcher.params.get("worker") is False) 118 | self.assertTrue(dispatcher.params.get("_title_") == "test") 119 | self.assertTrue(dispatcher.callback_params.get("testdata") is True) 120 | self.assertTrue(dispatcher.callback_params.get("worker") is False) 121 | 122 | @unittest.skipUnless(PY3, "The pickled string is specific to python 3") 123 | def test_parse_params_pickle_py3(self): 124 | dispatcher = support.Dispatcher() 125 | with mock_argv(["plugin://script.module.codequick/test/tester", 96, 126 | "?_pickle_=8004952c000000000000007d94288c08746573746461746194888c06776f726b657294898c075f74697" 127 | "46c655f948c047465737494752e"]): 128 | dispatcher.parse_args() 129 | 130 | self.assertEqual(dispatcher.selector, "/test/tester") 131 | self.assertTrue(dispatcher.params.get("testdata") is True) 132 | self.assertTrue(dispatcher.params.get("worker") is False) 133 | self.assertTrue(dispatcher.params.get("_title_") == "test") 134 | self.assertTrue(dispatcher.callback_params.get("testdata") is True) 135 | self.assertTrue(dispatcher.callback_params.get("worker") is False) 136 | 137 | def test_register_metacall(self): 138 | def root(): 139 | pass 140 | 141 | self.dispatcher.register_delayed(root, [], {}) 142 | self.assertListEqual(self.dispatcher.registered_delayed, [(root, [], {})]) 143 | 144 | def test_metacalls(self): 145 | class Executed(object): 146 | yes = False 147 | 148 | def root(): 149 | Executed.yes = True 150 | raise RuntimeError("should not be raised") 151 | 152 | self.dispatcher.register_delayed(root, [], {}, 0) 153 | self.dispatcher.run_delayed() 154 | self.assertTrue(Executed.yes) 155 | 156 | def test_register_root(self): 157 | def root(): 158 | pass 159 | 160 | callback = self.dispatcher.register_callback(root, route.Route, {}) 161 | self.assertIn("root", self.dispatcher.registered_routes) 162 | self.assertIsInstance(callback.route, support.Route) 163 | self.assertTrue(inspect.ismethod(callback.test)) 164 | 165 | def test_register_non_root(self): 166 | def listing(): 167 | pass 168 | 169 | callback = self.dispatcher.register_callback(listing, route.Route, {}) 170 | self.assertIn("/tests/test_support/listing", self.dispatcher.registered_routes) 171 | self.assertIsInstance(callback.route, support.Route) 172 | self.assertTrue(inspect.ismethod(callback.test)) 173 | 174 | def test_register_duplicate(self): 175 | def root(): 176 | pass 177 | 178 | self.dispatcher.register_callback(root, route.Route, {}) 179 | self.dispatcher.register_callback(root, route.Route, {}) 180 | 181 | def test_dispatch(self): 182 | class Executed(object): 183 | yes = False 184 | 185 | def root(_): 186 | Executed.yes = True 187 | return False 188 | 189 | self.dispatcher.register_callback(root, route.Route, {}) 190 | 191 | with mock_argv(["plugin://script.module.codequick", 96, ""]): 192 | self.dispatcher.run_callback() 193 | 194 | self.assertTrue(Executed.yes) 195 | 196 | def test_dispatch_script(self): 197 | class Executed(object): 198 | yes = False 199 | 200 | def root(_): 201 | Executed.yes = True 202 | return False 203 | 204 | self.dispatcher.register_callback(root, script.Script, {}) 205 | self.dispatcher.run_callback() 206 | self.assertTrue(Executed.yes) 207 | 208 | def test_dispatch_fail(self): 209 | """Checks that error is caught and not raised.""" 210 | class Executed(object): 211 | yes = False 212 | 213 | def root(_): 214 | Executed.yes = True 215 | raise RuntimeError("testing error") 216 | 217 | self.dispatcher.register_callback(root, route.Route, {}) 218 | 219 | with mock_argv(["plugin://script.module.codequick", 96, ""]): 220 | self.dispatcher.run_callback() 221 | 222 | self.assertTrue(Executed.yes) 223 | 224 | def test_dispatch_fail_unicode_error(self): 225 | """Checks that error is caught and not raised.""" 226 | class Executed(object): 227 | yes = False 228 | 229 | def root(_): 230 | Executed.yes = True 231 | raise RuntimeError(u"testing \xe9") 232 | 233 | self.dispatcher.register_callback(root, route.Route, {}) 234 | with mock_argv(["plugin://script.module.codequick", 96, ""]): 235 | self.dispatcher.run_callback() 236 | 237 | self.assertTrue(Executed.yes) 238 | 239 | 240 | class BuildPath(unittest.TestCase): 241 | def setUp(self): 242 | # noinspection PyUnusedLocal 243 | @route.Route.register 244 | def root(_, one=1, two=2): 245 | pass 246 | 247 | self.callback = root 248 | 249 | def tearDown(self): 250 | support.dispatcher.reset() 251 | del support.dispatcher.registered_routes["root"] 252 | 253 | def test_build_path_no_args(self): 254 | ret = support.build_path() 255 | self.assertEqual(ret, "plugin://script.module.codequick/root/") 256 | 257 | def test_build_new_path(self): 258 | ret = support.build_path(self.callback) 259 | self.assertEqual(ret, "plugin://script.module.codequick/root/") 260 | 261 | @unittest.skipIf(PY3, "The pickled string is specific to python 2") 262 | def test_build_path_new_args_py2(self): 263 | ret = support.build_path(self.callback, query={"testdata": "data"}) 264 | self.assertEqual("plugin://script.module.codequick/root/?_pickle_=" 265 | "80027d71015508746573746461746171025504646174617103732e", ret) 266 | 267 | @unittest.skipUnless(PY3, "The pickled string is specific to python 2") 268 | def test_build_path_new_args_py3(self): 269 | ret = support.build_path(self.callback, query={"testdata": "data"}) 270 | self.assertEqual("plugin://script.module.codequick/root/?_pickle_=" 271 | "80049516000000000000007d948c08746573746461746194" 272 | "8c046461746194732e", ret) 273 | 274 | @unittest.skipIf(PY3, "The pickled string is specific to python 2") 275 | def test_build_path_extra_args_py2(self): 276 | support.dispatcher.params["_title_"] = "video" 277 | try: 278 | ret = support.build_path(self.callback, testdata="data") 279 | self.assertEqual("plugin://script.module.codequick/root/?_pickle_=" 280 | "80027d71012855075f7469746c655f71025505766964656" 281 | "f71035508746573746461746171045504646174617105752e", ret) 282 | finally: 283 | del support.dispatcher.params["_title_"] 284 | 285 | @unittest.skipUnless(PY3, "The pickled string is specific to python 2") 286 | def test_build_path_extra_args_py3(self): 287 | support.dispatcher.params["_title_"] = "video" 288 | try: 289 | ret = support.build_path(self.callback, testdata="data") 290 | self.assertEqual("plugin://script.module.codequick/root/?_pickle_=" 291 | "80049529000000000000007d94288c075f7469746c655f94" 292 | "8c05766964656f948c087465737464617461948c046461746194752e", ret) 293 | finally: 294 | del support.dispatcher.params["_title_"] 295 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/resolver.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | # Standard Library Imports 5 | import logging 6 | import inspect 7 | 8 | # Kodi imports 9 | import xbmcplugin 10 | import xbmcgui 11 | import xbmc 12 | 13 | # Package imports 14 | from codequick.script import Script 15 | from codequick.support import build_path, logger_id 16 | from codequick.utils import unicode_type, ensure_unicode 17 | from codequick import localized 18 | 19 | __all__ = ["Resolver"] 20 | 21 | # Logger specific to this module 22 | logger = logging.getLogger("%s.resolver" % logger_id) 23 | 24 | 25 | class Resolver(Script): 26 | """ 27 | This class is used to create "Resolver" callbacks. Resolver callbacks are callbacks that 28 | return playable video URL's which Kodi can play. 29 | 30 | Resolver inherits all methods and attributes from :class:`script.Script`. 31 | 32 | The possible return types from Resolver Callbacks are. 33 | * ``str``: URL as type "str". 34 | * ``iterable``: "List" or "tuple", consisting of URL's, "listItem's" or a "tuple" consisting of (title, URL). 35 | * ``dict``: "Dictionary" consisting of "title" as the key and the URL as the value. 36 | * ``listItem``: A :class:`codequick.Listitem` object with required data already set e.g. "label" and "path". 37 | * ``generator``: A Python "generator" that return's one or more URL's. 38 | * ``False``: This will cause the "resolver call" to quit silently, without raising a RuntimeError. 39 | 40 | .. note:: If multiple URL's are given, a playlist will be automaticly created. 41 | 42 | :raises RuntimeError: If no content was returned from callback. 43 | :raises ValueError: If returned url is invalid. 44 | 45 | :example: 46 | >>> from codequick import Resolver, Route, Listitem 47 | >>> 48 | >>> @Route.register 49 | >>> def root(_): 50 | >>> yield Listitem.from_dict("Play video", play_video, 51 | >>> params={"url": "https://www.youtube.com/watch?v=RZuVTOk6ePM"}) 52 | >>> 53 | >>> @Resolver.register 54 | >>> def play_video(plugin, url): 55 | >>> # Extract a playable video url using youtubeDL 56 | >>> return plugin.extract_source(url) 57 | """ 58 | # Change listitem type to 'player' 59 | is_playable = True 60 | 61 | def __init__(self): 62 | super(Resolver, self).__init__() 63 | self.playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) 64 | self._extra_commands = {} # Extra options that are passed to listitem 65 | 66 | def __call__(self, route, args, kwargs): 67 | results = super(Resolver, self).__call__(route, args, kwargs) 68 | return self._process_results(results) 69 | 70 | def create_loopback(self, url, **next_params): # Undocumented 71 | """ 72 | Create a playlist where the second item loops back to add-on to load next video. 73 | 74 | Also useful for continuous playback of videos with no foreseeable end. For example, party mode. 75 | 76 | :param str url: URL of the first playable item. 77 | :param next_params: [opt] "Keyword" arguments to add to the loopback request when accessing the next video. 78 | 79 | :returns: The Listitem that Kodi will play. 80 | :rtype: xbmcgui.ListItem 81 | """ 82 | # Video Playlist 83 | playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) 84 | 85 | # Main Playable listitem 86 | main_listitem = xbmcgui.ListItem() 87 | main_listitem.setPath(url) 88 | 89 | # When called from a loopback we just add title to main listitem 90 | if self._title.startswith(u"_loopback_"): 91 | main_listitem.setLabel(self._title.split(u" - ", 1)[1]) 92 | next_params["_title_"] = self._title 93 | else: 94 | # Create playlist for loopback calling 95 | # The first item is the playable listitem 96 | main_listitem.setLabel(self._title) 97 | next_params["_title_"] = u"_loopback_ - %s" % self._title 98 | playlist.clear() 99 | playlist.add(url, main_listitem) 100 | 101 | # Create Loopback listitem 102 | loop_listitem = xbmcgui.ListItem() 103 | loop_listitem.setLabel(next_params["_title_"]) 104 | 105 | # Build a loopback url that callback to the addon to fetch the next video 106 | loopback_url = build_path(**next_params) 107 | loop_listitem.setPath(loopback_url) 108 | playlist.add(loopback_url, loop_listitem) 109 | 110 | # Retrun the playable listitem 111 | return main_listitem 112 | 113 | def extract_source(self, url, quality=None, **params): 114 | """ 115 | Extract video URL using "YouTube.DL". 116 | 117 | YouTube.DL provides access to hundreds of sites. 118 | 119 | .. seealso:: 120 | 121 | The list of supported sites can be found at: 122 | 123 | https://rg3.github.io/youtube-dl/supportedsites.html 124 | 125 | Quality options are. 126 | * 0 = SD, 127 | * 1 = 720p, 128 | * 2 = 1080p, 129 | * 3 = Highest Available 130 | 131 | :param str url: URL of the video source, where the playable video can be extracted from. 132 | :param int quality: [opt] Override YouTube.DL's quality setting. 133 | :param params: Optional "Keyword" arguments of YouTube.DL parameters. 134 | 135 | :returns: The playable video url 136 | :rtype: str 137 | 138 | .. seealso:: 139 | 140 | The list of available parameters can be found at. 141 | 142 | https://github.com/rg3/youtube-dl#options 143 | """ 144 | 145 | def ytdl_logger(record): 146 | if record.startswith("ERROR:"): 147 | # Save error rocord for raising later, outside of the callback 148 | # YoutubeDL ignores errors inside callbacks 149 | stored_errors.append("Youtube-DL: " + record[7:]) 150 | 151 | self.log(record) 152 | return True 153 | 154 | # Setup YoutubeDL module 155 | # noinspection PyUnresolvedReferences 156 | from YDStreamExtractor import getVideoInfo, setOutputCallback, overrideParam 157 | setOutputCallback(ytdl_logger) 158 | stored_errors = [] 159 | 160 | # Override youtube_dl parmeters 161 | for key, value in params.items(): 162 | overrideParam(key, value) 163 | 164 | # Atempt to extract video source 165 | video_info = getVideoInfo(url, quality) 166 | if video_info: 167 | if video_info.hasMultipleStreams(): 168 | # More than one stream found, Ask the user to select a stream 169 | video_info = self._source_selection(video_info) 170 | 171 | if video_info: 172 | # Content Lookup needs to be disabled for dailymotion videos to work 173 | if video_info.sourceName == "dailymotion": 174 | self._extra_commands["setContentLookup"] = False 175 | 176 | return video_info.streamURL() 177 | 178 | # Raise any stored errors 179 | elif stored_errors: 180 | raise RuntimeError(stored_errors[0]) 181 | 182 | def _source_selection(self, video_info): 183 | """ 184 | Ask user whitch video stream to play. 185 | 186 | :param video_info: YDStreamExtractor video_info object. 187 | :returns: video_info object with the video pre selection. 188 | """ 189 | display_list = [] 190 | # Populate list with name of extractor ('YouTube') and video title. 191 | for stream in video_info.streams(): 192 | data = "%s - %s" % (stream["ytdl_format"]["extractor"].title(), stream["title"]) 193 | display_list.append(data) 194 | 195 | dialog = xbmcgui.Dialog() 196 | ret = dialog.select(self.localize(localized.SELECT_PLAYBACK_ITEM), display_list) 197 | if ret >= 0: 198 | video_info.selectStream(ret) 199 | return video_info 200 | 201 | def _create_playlist(self, urls): 202 | """ 203 | Create playlist for kodi and return back the first item of that playlist to play. 204 | 205 | :param list urls: Set of urls that will be used in the creation of the playlist. 206 | List may consist of urls or listitem objects. 207 | 208 | :returns The first listitem of the playlist. 209 | :rtype: xbmcgui.ListItem 210 | """ 211 | # Loop each item to create playlist 212 | listitems = [self._process_item(*item) for item in enumerate(urls, 1)] 213 | 214 | # Populate Playlis 215 | for item in listitems[1:]: 216 | self.playlist.add(item.getPath(), item) 217 | 218 | # Return the first playlist item 219 | return listitems[0] 220 | 221 | def _process_item(self, count, url): 222 | """ 223 | Process the playlist item and add to kodi playlist. 224 | 225 | :param int count: The part number of the item 226 | :param str url: The resolved object 227 | """ 228 | # Kodi original listitem object 229 | if isinstance(url, xbmcgui.ListItem): 230 | return url 231 | # Custom listitem object 232 | elif isinstance(url, Listitem): 233 | # noinspection PyProtectedMember 234 | return url.build()[1] 235 | else: 236 | # Not already a listitem object 237 | listitem = xbmcgui.ListItem() 238 | if isinstance(url, (list, tuple)): 239 | title, url = url 240 | title = ensure_unicode(title) 241 | else: 242 | title = self._title 243 | 244 | # Create listitem with new title 245 | listitem.setLabel(u"%s Part %i" % (title, count) if count > 1 else title) 246 | listitem.setInfo("video", {"title": title}) 247 | listitem.setPath(url) 248 | return listitem 249 | 250 | def _process_generator(self, resolved): 251 | """ 252 | Populate the kodi playlist in the background from a generator. 253 | 254 | :param resolved: The resolved generator to fetch the rest of the videos from 255 | """ 256 | for item in enumerate(filter(None, resolved), 2): 257 | listitem = self._process_item(*item) 258 | self.playlist.add(listitem.getPath(), listitem) 259 | 260 | def _process_results(self, resolved): 261 | """ 262 | Construct playable listitem and send to kodi. 263 | 264 | :param resolved: The resolved url to send back to kodi. 265 | """ 266 | if resolved: 267 | # Create listitem object if resolved is a string or unicode 268 | if isinstance(resolved, (bytes, unicode_type)): 269 | listitem = xbmcgui.ListItem() 270 | listitem.setPath(resolved) 271 | 272 | # Directly use resoleved if its already a listitem 273 | elif isinstance(resolved, xbmcgui.ListItem): 274 | listitem = resolved 275 | 276 | # Extract original kodi listitem from custom listitem 277 | elif isinstance(resolved, Listitem): 278 | # noinspection PyProtectedMember 279 | listitem = resolved.build()[1] 280 | 281 | # Create playlist if resolved object is a list of urls 282 | elif isinstance(resolved, (list, tuple)): 283 | listitem = self._create_playlist(resolved) 284 | 285 | # Fetch the first element of the generator and process the rest in the background 286 | elif inspect.isgenerator(resolved): 287 | listitem = self._process_item(1, next(resolved)) 288 | self.register_delayed(self._process_generator, resolved) 289 | 290 | # Create playlist if resolved is a dict of {title: url} 291 | elif hasattr(resolved, "items"): 292 | items = resolved.items() 293 | listitem = self._create_playlist(items) 294 | 295 | else: 296 | # Resolved url must be invalid 297 | raise ValueError("resolver returned invalid url of type: '%s'" % type(resolved)) 298 | 299 | logger.debug("Resolved Url: %s", listitem.getPath()) 300 | 301 | elif resolved is False: 302 | # A empty listitem is still required even if 'resolved' is False 303 | # From time to time Kodi will report that 'Playback failed' 304 | # there is nothing that can be done about that. 305 | listitem = xbmcgui.ListItem() 306 | else: 307 | raise RuntimeError(self.localize(localized.NO_VIDEO)) 308 | 309 | # Add extra parameters to listitem 310 | if "setContentLookup" in self._extra_commands: 311 | value = self._extra_commands["setContentLookup"] 312 | listitem.setContentLookup(value) 313 | 314 | # Send playable listitem to kodi 315 | xbmcplugin.setResolvedUrl(self.handle, bool(resolved), listitem) 316 | 317 | 318 | # Now we can import the listing module 319 | from codequick.listing import Listitem 320 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/support.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | # Standard Library Imports 5 | import importlib 6 | import binascii 7 | import inspect 8 | import logging 9 | import time 10 | import sys 11 | import re 12 | 13 | # Kodi imports 14 | import xbmcaddon 15 | import xbmcgui 16 | import xbmc 17 | 18 | # Package imports 19 | from codequick.utils import parse_qs, ensure_native_str, urlparse, PY3, unicode_type 20 | 21 | try: 22 | # noinspection PyPep8Naming 23 | import cPickle as pickle 24 | except ImportError: # pragma: no cover 25 | import pickle 26 | 27 | if PY3: 28 | from inspect import getfullargspec 29 | PICKLE_PROTOCOL = 4 30 | else: 31 | # noinspection PyDeprecation 32 | from inspect import getargspec as getfullargspec 33 | PICKLE_PROTOCOL = 2 34 | 35 | script_data = xbmcaddon.Addon("script.module.codequick") 36 | addon_data = xbmcaddon.Addon() 37 | 38 | plugin_id = addon_data.getAddonInfo("id") 39 | logger_id = re.sub("[ .]", "-", addon_data.getAddonInfo("name")) 40 | 41 | # Logger specific to this module 42 | logger = logging.getLogger("%s.support" % logger_id) 43 | 44 | # Listitem auto sort methods 45 | auto_sort = set() 46 | 47 | logging_map = { 48 | 10: xbmc.LOGDEBUG, 49 | 20: xbmc.LOGINFO if PY3 else xbmc.LOGNOTICE, 50 | 30: xbmc.LOGWARNING, 51 | 40: xbmc.LOGERROR, 52 | 50: xbmc.LOGFATAL, 53 | } 54 | 55 | 56 | class RouteMissing(KeyError): 57 | """ 58 | Exception class that is raisd when no 59 | route is found in the registered routes. 60 | """ 61 | 62 | 63 | class KodiLogHandler(logging.Handler): 64 | """ 65 | Custom Logger Handler to forward logs to Kodi. 66 | 67 | Log records will automatically be converted from unicode to utf8 encoded strings. 68 | All debug messages will be stored locally and outputed as warning messages if a critical error occurred. 69 | This is done so that debug messages will appear on the normal kodi log file without having to enable debug logging. 70 | 71 | :ivar debug_msgs: Local store of degub messages. 72 | """ 73 | def __init__(self): 74 | super(KodiLogHandler, self).__init__() 75 | self.setFormatter(logging.Formatter("[%(name)s] %(message)s")) 76 | self.debug_msgs = [] 77 | 78 | def emit(self, record): # type: (logging.LogRecord) -> None 79 | """Forward the log record to kodi, lets kodi handle the logging.""" 80 | formatted_msg = ensure_native_str(self.format(record)) 81 | log_level = record.levelno 82 | 83 | # Forward the log record to kodi with translated log level 84 | xbmc.log(formatted_msg, logging_map.get(log_level, 10)) 85 | 86 | # Keep a history of all debug records so they can be logged later if a critical error occurred 87 | # Kodi by default, won't show debug messages unless debug logging is enabled 88 | if log_level == 10: 89 | self.debug_msgs.append(formatted_msg) 90 | 91 | # If a critical error occurred, log all debug messages as warnings 92 | elif log_level == 50 and self.debug_msgs: 93 | xbmc.log("###### debug ######", xbmc.LOGWARNING) 94 | for msg in self.debug_msgs: 95 | xbmc.log(msg, xbmc.LOGWARNING) 96 | xbmc.log("###### debug ######", xbmc.LOGWARNING) 97 | 98 | 99 | class CallbackRef(object): 100 | __slots__ = ("path", "parent", "is_playable", "is_folder") 101 | 102 | def __init__(self, path, parent): 103 | self.path = path.rstrip("/").replace(":", "/") 104 | self.is_playable = parent.is_playable 105 | self.is_folder = parent.is_folder 106 | self.parent = parent 107 | 108 | def __eq__(self, other): 109 | return self.path == other.path 110 | 111 | 112 | class Route(CallbackRef): 113 | """ 114 | Handle callback route data. 115 | 116 | :param callback: The callable callback function. 117 | :param parent: The parent class that will handle the response from callback. 118 | :param str path: The route path to func/class. 119 | :param dict parameters: Dict of parameters to pass to plugin instance. 120 | """ 121 | __slots__ = ("function", "callback", "parameters") 122 | 123 | def __init__(self, callback, parent, path, parameters): 124 | # Register a class callback 125 | if inspect.isclass(callback): 126 | msg = "Use of class based callbacks are Deprecated, please use function callbacks" 127 | logger.warning("DeprecationWarning: " + msg) 128 | if hasattr(callback, "run"): 129 | parent = callback 130 | self.function = callback.run 131 | callback.test = staticmethod(self.unittest_caller) 132 | else: 133 | raise NameError("missing required 'run' method for class: '{}'".format(callback.__name__)) 134 | else: 135 | # Register a function callback 136 | callback.test = self.unittest_caller 137 | self.parameters = parameters 138 | self.function = callback 139 | 140 | super(Route, self).__init__(path, parent) 141 | self.callback = callback 142 | 143 | def unittest_caller(self, *args, **kwargs): 144 | """ 145 | Function to allow callbacks to be easily called from unittests. 146 | Parent argument will be auto instantiated and passed to callback. 147 | This basically acts as a constructor to callback. 148 | 149 | Test specific Keyword args: 150 | execute_delayed: Execute any registered delayed callbacks. 151 | 152 | :param args: Positional arguments to pass to callback. 153 | :param kwargs: Keyword arguments to pass to callback. 154 | :returns: The response from the callback function. 155 | """ 156 | execute_delayed = kwargs.pop("execute_delayed", False) 157 | 158 | # Change the selector to match callback route been tested 159 | # This will ensure that the plugin paths are currect 160 | dispatcher.selector = self.path 161 | 162 | # Update support params with the params 163 | # that are to be passed to callback 164 | if args: 165 | dispatcher.params["_args_"] = args 166 | 167 | if kwargs: 168 | dispatcher.params.update(kwargs) 169 | 170 | # Instantiate the parent 171 | parent_ins = self.parent() 172 | 173 | try: 174 | # Now we are ready to call the callback function and return its results 175 | results = self.function(parent_ins, *args, **kwargs) 176 | if inspect.isgenerator(results): 177 | results = list(results) 178 | 179 | except Exception: 180 | raise 181 | 182 | else: 183 | # Execute Delated callback functions if any 184 | if execute_delayed: 185 | dispatcher.run_delayed() 186 | 187 | return results 188 | 189 | finally: 190 | # Reset global datasets 191 | dispatcher.reset() 192 | auto_sort.clear() 193 | 194 | 195 | class Dispatcher(object): 196 | """Class to handle registering and dispatching of callback functions.""" 197 | 198 | def __init__(self): 199 | self.registered_delayed = [] 200 | self.registered_routes = {} 201 | self.callback_params = {} 202 | self.selector = "root" 203 | self.params = {} 204 | self.handle = -1 205 | 206 | def reset(self): 207 | """Reset session parameters.""" 208 | self.registered_delayed[:] = [] 209 | self.callback_params.clear() 210 | kodi_logger.debug_msgs = [] 211 | self.selector = "root" 212 | self.params.clear() 213 | auto_sort.clear() 214 | 215 | def parse_args(self, redirect=None): 216 | """Extract arguments given by Kodi""" 217 | _, _, route, raw_params, _ = urlparse.urlsplit(redirect if redirect else sys.argv[0] + sys.argv[2]) 218 | self.selector = route if len(route) > 1 else "root" 219 | self.handle = int(sys.argv[1]) 220 | 221 | if raw_params: 222 | params = parse_qs(raw_params) 223 | self.params.update(params) 224 | 225 | # Unpickle pickled data 226 | if "_pickle_" in params: 227 | unpickled = pickle.loads(binascii.unhexlify(self.params.pop("_pickle_"))) 228 | self.params.update(unpickled) 229 | 230 | # Construct a separate dictionary for callback specific parameters 231 | self.callback_params = {key: value for key, value in self.params.items() 232 | if not (key.startswith(u"_") and key.endswith(u"_"))} 233 | 234 | def get_route(self, path=None): # type: (str) -> Route 235 | """ 236 | Return the given route callback. 237 | 238 | :param str path: The route path, if not given defaults to current callback 239 | """ 240 | path = path.rstrip("/") if path else self.selector.rstrip("/") 241 | 242 | # Attempt to import the module where the route 243 | # is located if it's not already registered 244 | if path not in self.registered_routes: 245 | module_path = "resources.lib.main" if path == "root" else ".".join(path.strip("/").split("/")[:-1]) 246 | logger.debug("Attempting to import route: %s", module_path) 247 | try: 248 | importlib.import_module(module_path) 249 | except ImportError: 250 | raise RouteMissing("unable to import route module: %s" % module_path) 251 | try: 252 | return self.registered_routes[path] 253 | except KeyError: 254 | raise RouteMissing(path) 255 | 256 | def register_callback(self, callback, parent, parameters): 257 | """Register route callback function""" 258 | # Construct route path 259 | path = callback.__name__.lower() 260 | if path != "root": 261 | path = "/{}/{}".format(callback.__module__.strip("_").replace(".", "/"), callback.__name__).lower() 262 | 263 | # Register callback 264 | if path in self.registered_routes: 265 | logger.debug("encountered duplicate route: '%s'", path) 266 | 267 | self.registered_routes[path] = route = Route(callback, parent, path, parameters) 268 | callback.route = route 269 | return callback 270 | 271 | def register_delayed(self, *callback): 272 | """Register a function that will be called later, after content has been listed.""" 273 | self.registered_delayed.append(callback) 274 | 275 | # noinspection PyIncorrectDocstring 276 | def run_callback(self, process_errors=True, redirect=None): 277 | """ 278 | The starting point of the add-on. 279 | 280 | This function will handle the execution of the "callback" functions. 281 | The callback function that will be executed, will be auto selected. 282 | 283 | The "root" callback, is the callback that will be the initial 284 | starting point for the add-on. 285 | 286 | :param bool process_errors: Enable/Disable internal error handler. (default => True) 287 | :returns: Returns None if no errors were raised, or if errors were raised and process_errors is 288 | True (default) then the error Exception that was raised will be returned. 289 | 290 | returns the error Exception if an error ocurred. 291 | :rtype: Exception or None 292 | """ 293 | self.reset() 294 | self.parse_args(redirect) 295 | logger.debug("Dispatching to route: '%s'", self.selector) 296 | logger.debug("Callback parameters: '%s'", self.callback_params) 297 | 298 | try: 299 | # Fetch the controling class and callback function/method 300 | route = self.get_route(self.selector) 301 | execute_time = time.time() 302 | 303 | # Initialize controller and execute callback 304 | parent_ins = route.parent() 305 | arg_params = self.params.get("_args_", []) 306 | redirect = parent_ins(route, arg_params, self.callback_params) 307 | 308 | except Exception as e: 309 | self.run_delayed(e) 310 | # Don't do anything with the error 311 | # if process_errors is disabled 312 | if not process_errors: 313 | raise 314 | 315 | try: 316 | msg = str(e) 317 | except UnicodeEncodeError: 318 | # This is python 2 only code 319 | # We only use unicode to fetch message when we 320 | # know that we are dealing with unicode data 321 | msg = unicode_type(e).encode("utf8") 322 | 323 | # Log the error in both the gui and the kodi log file 324 | logger.exception(msg) 325 | dialog = xbmcgui.Dialog() 326 | dialog.notification(e.__class__.__name__, msg, addon_data.getAddonInfo("icon")) 327 | return e 328 | 329 | else: 330 | logger.debug("Route Execution Time: %ims", (time.time() - execute_time) * 1000) 331 | self.run_delayed() 332 | if redirect: 333 | self.run_callback(process_errors, redirect) 334 | 335 | def run_delayed(self, exception=None): 336 | """Execute all delayed callbacks, if any.""" 337 | if self.registered_delayed: 338 | # Time before executing callbacks 339 | start_time = time.time() 340 | 341 | # Execute in order of last in first out (LIFO). 342 | while self.registered_delayed: 343 | func, args, kwargs, function_type = self.registered_delayed.pop() 344 | if function_type == 2 or bool(exception) == function_type: 345 | # Add raised exception to callback if requested 346 | if "exception" in getfullargspec(func).args: 347 | kwargs["exception"] = exception 348 | 349 | try: 350 | func(*args, **kwargs) 351 | except Exception as e: 352 | logger.exception(str(e)) 353 | 354 | # Log execution time of callbacks 355 | logger.debug("Callbacks Execution Time: %ims", (time.time() - start_time) * 1000) 356 | 357 | 358 | def build_path(callback=None, args=None, query=None, **extra_query): 359 | """ 360 | Build addon url that can be passed to kodi for kodi to use when calling listitems. 361 | 362 | :param callback: [opt] The route selector path referencing the callback object. (default => current route selector) 363 | :param tuple args: [opt] Positional arguments that will be add to plugin path. 364 | :param dict query: [opt] A set of query key/value pairs to add to plugin path. 365 | :param extra_query: [opt] Keyword arguments if given will be added to the current set of querys. 366 | 367 | :return: Plugin url for kodi. 368 | :rtype: str 369 | """ 370 | 371 | # Set callback to current callback if not given 372 | if callback and hasattr(callback, "route"): 373 | route = callback.route 374 | elif isinstance(callback, CallbackRef): 375 | route = callback 376 | elif callback: 377 | msg = "passing in callback path is deprecated, use callback reference 'Route.ref' instead" 378 | logger.warning("DeprecationWarning: " + msg) 379 | route = dispatcher.get_route(callback) 380 | else: 381 | route = dispatcher.get_route() 382 | 383 | # Convert args to keyword args if required 384 | if args: 385 | query["_args_"] = args 386 | 387 | # If extra querys are given then append the 388 | # extra querys to the current set of querys 389 | if extra_query: 390 | query = dispatcher.params.copy() 391 | query.update(extra_query) 392 | 393 | # Encode the query parameters using json 394 | if query: 395 | pickled = binascii.hexlify(pickle.dumps(query, protocol=PICKLE_PROTOCOL)) 396 | query = "_pickle_={}".format(pickled.decode("ascii") if PY3 else pickled) 397 | 398 | # Build kodi url with new path and query parameters 399 | # NOTE: Kodi really needs a trailing '/' 400 | return urlparse.urlunsplit(("plugin", plugin_id, route.path + "/", query, "")) 401 | 402 | 403 | # Setup kodi logging 404 | kodi_logger = KodiLogHandler() 405 | base_logger = logging.getLogger() 406 | base_logger.addHandler(kodi_logger) 407 | base_logger.setLevel(logging.DEBUG) 408 | base_logger.propagate = False 409 | 410 | # Dispatcher to manage route callbacks 411 | dispatcher = Dispatcher() 412 | run = dispatcher.run_callback 413 | get_route = dispatcher.get_route 414 | -------------------------------------------------------------------------------- /script.module.codequick/lib/codequick/script.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | # Standard Library Imports 5 | import logging 6 | import inspect 7 | import os 8 | 9 | # Kodi imports 10 | import xbmcaddon 11 | import xbmcvfs 12 | import xbmcgui 13 | import xbmc 14 | 15 | # Package imports 16 | from codequick.utils import ensure_unicode, ensure_native_str, unicode_type, string_map 17 | from codequick.support import dispatcher, script_data, addon_data, logger_id, CallbackRef, PY3 18 | 19 | # Matrix changes 20 | translatePath = xbmcvfs.translatePath if PY3 else xbmc.translatePath 21 | 22 | __all__ = ["Script", "Settings"] 23 | 24 | # Logger used by the addons 25 | addon_logger = logging.getLogger(logger_id) 26 | 27 | 28 | class Settings(object): 29 | """Settings class to handle the getting and setting of "add-on" settings.""" 30 | 31 | def __getitem__(self, key): 32 | """ 33 | Returns the value of a setting as a "unicode string". 34 | 35 | :param str key: ID of the setting to access. 36 | 37 | :return: Setting as a "unicode string". 38 | :rtype: str 39 | """ 40 | return addon_data.getSetting(key) 41 | 42 | def __setitem__(self, key, value): 43 | """ 44 | Set add-on setting. 45 | 46 | :param str key: ID of the setting. 47 | :param str value: Value of the setting. 48 | """ 49 | # noinspection PyTypeChecker 50 | addon_data.setSetting(key, ensure_unicode(value)) 51 | 52 | def __delitem__(self, key): # type: (str) -> None 53 | """Set an add-on setting to a blank string.""" 54 | addon_data.setSetting(key, "") 55 | 56 | @staticmethod 57 | def get_string(key, addon_id=None): 58 | """ 59 | Returns the value of a setting as a "unicode string". 60 | 61 | :param str key: ID of the setting to access. 62 | :param str addon_id: [opt] ID of another add-on to extract settings from. 63 | 64 | :raises RuntimeError: If ``addon_id`` is given and there is no add-on with given ID. 65 | 66 | :return: Setting as a "unicode string". 67 | :rtype: str 68 | """ 69 | if addon_id: 70 | return xbmcaddon.Addon(addon_id).getSetting(key) 71 | else: 72 | return addon_data.getSetting(key) 73 | 74 | @staticmethod 75 | def get_boolean(key, addon_id=None): 76 | """ 77 | Returns the value of a setting as a "Boolean". 78 | 79 | :param str key: ID of the setting to access. 80 | :param str addon_id: [opt] ID of another add-on to extract settings from. 81 | 82 | :raises RuntimeError: If ``addon_id`` is given and there is no add-on with given ID. 83 | 84 | :return: Setting as a "Boolean". 85 | :rtype: bool 86 | """ 87 | setting = Settings.get_string(key, addon_id).lower() 88 | return setting == u"true" or setting == u"1" 89 | 90 | @staticmethod 91 | def get_int(key, addon_id=None): 92 | """ 93 | Returns the value of a setting as a "Integer". 94 | 95 | :param str key: ID of the setting to access. 96 | :param str addon_id: [opt] ID of another add-on to extract settings from. 97 | 98 | :raises RuntimeError: If ``addon_id`` is given and there is no add-on with given ID. 99 | 100 | :return: Setting as a "Integer". 101 | :rtype: int 102 | """ 103 | return int(Settings.get_string(key, addon_id)) 104 | 105 | @staticmethod 106 | def get_number(key, addon_id=None): 107 | """ 108 | Returns the value of a setting as a "Float". 109 | 110 | :param str key: ID of the setting to access. 111 | :param str addon_id: [opt] ID of another addon to extract settings from. 112 | 113 | :raises RuntimeError: If ``addon_id`` is given and there is no addon with given ID. 114 | 115 | :return: Setting as a "Float". 116 | :rtype: float 117 | """ 118 | return float(Settings.get_string(key, addon_id)) 119 | 120 | 121 | class Script(object): 122 | """ 123 | This class is used to create "Script" callbacks. Script callbacks are callbacks 124 | that just execute code and return nothing. 125 | 126 | This class is also used as the base for all other types of callbacks i.e. 127 | :class:`codequick.Route` and :class:`codequick.Resolver`. 128 | """ 129 | # Set the listitem types to that of a script 130 | is_playable = False 131 | is_folder = False 132 | 133 | #: Critical logging level, maps to "xbmc.LOGFATAL". 134 | CRITICAL = 50 135 | #: Critical logging level, maps to "xbmc.LOGWARNING". 136 | WARNING = 30 137 | #: Critical logging level, maps to "xbmc.LOGERROR". 138 | ERROR = 40 139 | #: Critical logging level, maps to "xbmc.LOGDEBUG". 140 | DEBUG = 10 141 | #: Critical logging level, maps to "xbmc.LOGINFO". 142 | INFO = 20 143 | 144 | #: Kodi notification warning image. 145 | NOTIFY_WARNING = 'warning' 146 | #: Kodi notification error image. 147 | NOTIFY_ERROR = 'error' 148 | #: Kodi notification info image. 149 | NOTIFY_INFO = 'info' 150 | 151 | setting = Settings() 152 | """ 153 | Dictionary like interface of "add-on" settings. 154 | See :class:`script.Settings` for more details. 155 | """ 156 | 157 | #: Underlining logger object, for advanced use. See :class:`logging.Logger` for more details. 158 | logger = addon_logger 159 | 160 | #: Dictionary of all callback parameters, for advanced use. 161 | params = dispatcher.params 162 | 163 | def __init__(self): 164 | self._title = self.params.get(u"_title_", u"") 165 | self.handle = dispatcher.handle 166 | 167 | def __call__(self, route, args, kwargs): 168 | self.__dict__.update(route.parameters) 169 | return route.function(self, *args, **kwargs) 170 | 171 | @classmethod 172 | def ref(cls, path): 173 | """ 174 | When given a path to a callback function, will return a reference to that callback function. 175 | 176 | This is used as a way to link to a callback without the need to import it first. 177 | With this only the required module containing the callback is imported when callback is executed. 178 | This can be used to improve performance when dealing with lots of different callback functions. 179 | 180 | .. note: 181 | 182 | This method needs to be called from the same callback object type of 183 | the referenced callback. e.g. Script/Route/Resolver. 184 | 185 | The path structure is '//:function' where 'package' is the full package path. 186 | 'module' is the name of the modules containing the callback. 187 | And 'function' is the name of the callback function. 188 | 189 | :example: 190 | >>> from codequick import Route, Resolver, Listitem 191 | >>> item = Listitem() 192 | >>> 193 | >>> # Example of referencing a Route callback 194 | >>> item.set_callback(Route.ref("/resources/lib/videos:video_list")) 195 | >>> 196 | >>> # Example of referencing a Resolver callback 197 | >>> item.set_callback(Resolver.ref("/resources/lib/resolvers:play_video")) 198 | 199 | :param str path: The path to a callback function. 200 | :return: A callback reference object. 201 | """ 202 | return CallbackRef(path, cls) 203 | 204 | @classmethod 205 | def register(cls, func=None, **kwargs): 206 | """ 207 | Decorator used to register callback functions. 208 | 209 | Can be called with or without arguments. If arguments are given, they have to be "keyword only" arguments. 210 | The keyword arguments are parameters that are used by the plugin class instance. 211 | e.g. autosort=False to disable auto sorting for Route callbacks 212 | 213 | :example: 214 | >>> from codequick import Route, Listitem 215 | >>> 216 | >>> @Route.register 217 | >>> def root(_): 218 | >>> yield Listitem.from_dict("Extra videos", subfolder) 219 | >>> 220 | >>> @Route.register(cache_ttl=240, autosort=False, content_type="videos") 221 | >>> def subfolder(_): 222 | >>> yield Listitem.from_dict("Play video", "http://www.example.com/video1.mkv") 223 | 224 | :param function func: The callback function to register. 225 | :param kwargs: Keyword only arguments to pass to callback handler. 226 | :returns: A callback instance. 227 | :rtype: Callback 228 | """ 229 | if inspect.isfunction(func): 230 | return dispatcher.register_callback(func, parent=cls, parameters=kwargs) 231 | 232 | elif func is None: 233 | def wrapper(real_func): 234 | return dispatcher.register_callback(real_func, parent=cls, parameters=kwargs) 235 | return wrapper 236 | else: 237 | raise ValueError("Only keyword arguments are allowed") 238 | 239 | @staticmethod 240 | def register_delayed(func, *args, **kwargs): 241 | """ 242 | Registers a function that will be executed after Kodi has finished listing all "listitems". 243 | Since this function is called after the listitems has been shown, it will not slow down the 244 | listing of content. This is very useful for fetching extra metadata for later use. 245 | 246 | .. note:: 247 | 248 | Functions will be called in reverse order to the order they are added (LIFO). 249 | 250 | :param func: Function that will be called after "xbmcplugin.endOfDirectory" is called. 251 | :param args: "Positional" arguments that will be passed to function. 252 | :param kwargs: "Keyword" arguments that will be passed to function. 253 | 254 | .. note:: 255 | 256 | There is one optional keyword only argument ``function_type``. Values are as follows. 257 | * ``0`` Only run if no errors are raised. (Default) 258 | * ``1`` Only run if an error has occurred. 259 | * ``2`` Run regardless if an error was raised or not. 260 | 261 | .. note:: 262 | 263 | If there is an argument called exception in the delayed function callback and an error was raised, 264 | then that exception argument will be set to the raised exception object. 265 | Otherwise it will be set to None. 266 | """ 267 | function_type = kwargs.get("function_type", 0) 268 | dispatcher.register_delayed(func, args, kwargs, function_type) 269 | 270 | @staticmethod 271 | def log(msg, args=None, lvl=10): 272 | """ 273 | Logs a message with logging level of "lvl". 274 | 275 | Logging Levels. 276 | * :attr:`Script.DEBUG` 277 | * :attr:`Script.INFO` 278 | * :attr:`Script.WARNING` 279 | * :attr:`Script.ERROR` 280 | * :attr:`Script.CRITICAL` 281 | 282 | :param str msg: The message format string. 283 | :type args: list or tuple 284 | :param args: List of arguments which are merged into msg using the string formatting operator. 285 | :param int lvl: The logging level to use. default => 10 (Debug). 286 | 287 | .. Note:: 288 | 289 | When a log level of 50(CRITICAL) is given, all debug messages that were previously logged will 290 | now be logged as level 30(WARNING). This allows for debug messages to show in the normal Kodi 291 | log file when a CRITICAL error has occurred, without having to enable Kodi's debug mode. 292 | """ 293 | if args: 294 | addon_logger.log(lvl, msg, *args) 295 | else: 296 | addon_logger.log(lvl, msg) 297 | 298 | @staticmethod 299 | def notify(heading, message, icon=None, display_time=5000, sound=True): 300 | """ 301 | Send a notification to Kodi. 302 | 303 | Options for icon are. 304 | * :attr:`Script.NOTIFY_INFO` 305 | * :attr:`Script.NOTIFY_ERROR` 306 | * :attr:`Script.NOTIFY_WARNING` 307 | 308 | :param str heading: Dialog heading label. 309 | :param str message: Dialog message label. 310 | :param str icon: [opt] Icon image to use. (default => 'add-on icon image') 311 | 312 | :param int display_time: [opt] Ttime in "milliseconds" to show dialog. (default => 5000) 313 | :param bool sound: [opt] Whether or not to play notification sound. (default => True) 314 | """ 315 | # Ensure that heading, message and icon 316 | # is encoded into native str type 317 | heading = ensure_native_str(heading) 318 | message = ensure_native_str(message) 319 | icon = ensure_native_str(icon if icon else Script.get_info("icon")) 320 | 321 | dialog = xbmcgui.Dialog() 322 | dialog.notification(heading, message, icon, display_time, sound) 323 | 324 | @staticmethod 325 | def localize(string_id): 326 | """ 327 | Returns a translated UI string from addon localization files. 328 | 329 | .. note:: 330 | 331 | :data:`utils.string_map` 332 | needs to be populated before you can pass in a string as the reference. 333 | 334 | :param string_id: The numeric ID or gettext string ID of the localized string 335 | :type string_id: str or int 336 | 337 | :returns: Localized unicode string. 338 | :rtype: str 339 | 340 | :raises Keyword: if a gettext string ID was given but the string is not found in English :file:`strings.po`. 341 | :example: 342 | >>> Script.localize(30001) 343 | "Toutes les vidéos" 344 | >>> Script.localize("All Videos") 345 | "Toutes les vidéos" 346 | """ 347 | if isinstance(string_id, (str, unicode_type)): 348 | try: 349 | numeric_id = string_map[string_id] 350 | except KeyError: 351 | raise KeyError("no localization found for string id '%s'" % string_id) 352 | else: 353 | return addon_data.getLocalizedString(numeric_id) 354 | 355 | elif 30000 <= string_id <= 30999: 356 | return addon_data.getLocalizedString(string_id) 357 | elif 32000 <= string_id <= 32999: 358 | return script_data.getLocalizedString(string_id) 359 | else: 360 | return xbmc.getLocalizedString(string_id) 361 | 362 | @staticmethod 363 | def get_info(key, addon_id=None): 364 | """ 365 | Returns the value of an add-on property as a unicode string. 366 | 367 | Properties. 368 | * author 369 | * changelog 370 | * description 371 | * disclaimer 372 | * fanart 373 | * icon 374 | * id 375 | * name 376 | * path 377 | * profile 378 | * stars 379 | * summary 380 | * type 381 | * version 382 | 383 | :param str key: "Name" of the property to access. 384 | :param str addon_id: [opt] ID of another add-on to extract properties from. 385 | 386 | :return: Add-on property as a unicode string. 387 | :rtype: str 388 | 389 | :raises RuntimeError: If add-on ID is given and there is no add-on with given ID. 390 | """ 391 | if addon_id: 392 | # Extract property from a different add-on 393 | resp = xbmcaddon.Addon(addon_id).getAddonInfo(key) 394 | elif key == "path_global" or key == "profile_global": 395 | # Extract property from codequick addon 396 | resp = script_data.getAddonInfo(key[:key.find("_")]) 397 | else: 398 | # Extract property from the running addon 399 | resp = addon_data.getAddonInfo(key) 400 | 401 | # Check if path needs to be translated first 402 | if resp[:10] == "special://": # pragma: no cover 403 | resp = translatePath(resp) 404 | 405 | # Convert response to unicode 406 | path = resp.decode("utf8") if isinstance(resp, bytes) else resp 407 | 408 | # Create any missing directory 409 | if key.startswith("profile"): 410 | if not os.path.exists(path): # pragma: no cover 411 | os.mkdir(path) 412 | 413 | return path 414 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | --------------------------------------------------------------------------------