├── 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 |
--------------------------------------------------------------------------------