Browse Source

sataisītā sākotnējā versija

Ivars 6 years ago
parent
commit
8215948e36
45 changed files with 8086 additions and 0 deletions
  1. 183
    0
      addon.py
  2. 17
    0
      addon.xml
  3. 5
    0
      changelog.md
  4. 21
    0
      deploy.bat
  5. 2
    0
      get_version.py
  6. 72
    0
      kmake.bat
  7. 83
    0
      kodiswift/__init__.py
  8. 37
    0
      kodiswift/actions.py
  9. 18
    0
      kodiswift/cli/__init__.py
  10. 222
    0
      kodiswift/cli/app.py
  11. 78
    0
      kodiswift/cli/cli.py
  12. 101
    0
      kodiswift/cli/console.py
  13. 192
    0
      kodiswift/cli/create.py
  14. 19
    0
      kodiswift/cli/data/addon.py
  15. 17
    0
      kodiswift/cli/data/addon.xml
  16. 1
    0
      kodiswift/cli/data/resources/__init__.py
  17. 26
    0
      kodiswift/cli/data/resources/language/English/strings.po
  18. 1
    0
      kodiswift/cli/data/resources/lib/__init__.py
  19. 147
    0
      kodiswift/common.py
  20. 76
    0
      kodiswift/constants.py
  21. 339
    0
      kodiswift/listitem.py
  22. 104
    0
      kodiswift/logger.py
  23. 1
    0
      kodiswift/mockxbmc/__init__.py
  24. 1835
    0
      kodiswift/mockxbmc/polib.py
  25. 37
    0
      kodiswift/mockxbmc/utils.py
  26. 97
    0
      kodiswift/mockxbmc/xbmc.py
  27. 70
    0
      kodiswift/mockxbmc/xbmcaddon.py
  28. 67
    0
      kodiswift/mockxbmc/xbmcgui.py
  29. 88
    0
      kodiswift/mockxbmc/xbmcplugin.py
  30. 25
    0
      kodiswift/mockxbmc/xbmcvfs.py
  31. 155
    0
      kodiswift/module.py
  32. 357
    0
      kodiswift/plugin.py
  33. 46
    0
      kodiswift/request.py
  34. 163
    0
      kodiswift/storage.py
  35. 212
    0
      kodiswift/urls.py
  36. 575
    0
      kodiswift/xbmcmixin.py
  37. 1946
    0
      project.wpr
  38. 34
    0
      readme.md
  39. 0
    0
      resources/__init__.py
  40. BIN
      resources/icon.png
  41. 29
    0
      resources/language/English/strings.xml
  42. 0
    0
      resources/lib/__init__.py
  43. 236
    0
      resources/lib/photostation_api.py
  44. 32
    0
      resources/settings.xml
  45. 320
    0
      wingdbstub.py

+ 183
- 0
addon.py View File

@@ -0,0 +1,183 @@
1
+import os,os.path,sys, urllib, urlparse
2
+from kodiswift import Plugin, ListItem, storage
3
+from kodiswift import xbmc, xbmcgui, xbmcplugin, xbmcvfs, xbmcaddon, CLI_MODE, SortMethod
4
+from resources.lib import photostation_api
5
+try:
6
+    import wingdbstub
7
+except:
8
+    pass
9
+
10
+plugin = Plugin()
11
+
12
+view_mode = plugin.get_setting("view_mode",str)
13
+
14
+server = plugin.get_setting("server", str)
15
+user = plugin.get_setting("user", str)
16
+password = plugin.get_setting("password", str)
17
+page_size = plugin.get_setting("page_size", str)
18
+page_size = -1 if page_size == "All" else int(page_size)
19
+sorting = plugin.get_setting("sorting", str)
20
+order = plugin.get_setting("order", str)
21
+video_quality = plugin.get_setting("video_quality", str)
22
+picture_quality = plugin.get_setting("picture_quality", str)
23
+
24
+ps = photostation_api.PhotoStationAPI("http://home.blue.lv/photo")
25
+try:
26
+    ps.login(user, password)
27
+except Exception as e:
28
+    print "Error while logging %s/%s"%(user,password)
29
+    plugin.notify(str(e),"",10000)
30
+prefix = ""
31
+
32
+@plugin.route(".+" )
33
+def main():
34
+    # plugin://xxx/
35
+
36
+    global prefix
37
+    prefix = "%s://%s/"%(plugin.request.scheme,plugin.request.netloc)
38
+    data = plugin.request.url.replace(prefix,"")
39
+    data = urllib.unquote(data)
40
+    #default_oid = "album_323031372f323031372d30312d313320536c69646f73616e61"
41
+    default_oid = ""
42
+    if not data:
43
+        data = default_oid
44
+    oid = plugin.request.path[1:]
45
+    if not oid:
46
+        oid = default_oid
47
+    qs = dict(urlparse.parse_qsl(plugin.request.query_string))
48
+    if not "page" in qs:
49
+        page = 1
50
+    else:
51
+        page = int(qs["page"])
52
+
53
+    if not oid or oid.startswith("album"):
54
+        plugin.set_content("images")
55
+
56
+        offset = 0 if page_size == -1 else (page - 1) * page_size
57
+        limit = -1 if page_size == -1 else page_size
58
+        content = ps.get_album_list(oid, offset=offset, limit=limit, sort_by=sorting, sort_direction=order )
59
+        items = []
60
+        for f in content["items"]:
61
+            title = f["info"]["name"]
62
+            type = f["type"]
63
+            oid2 = f["id"]
64
+            #is_playable = True if type in ("video","photo") else False
65
+            is_folder = False if type in ("video","photo") else True
66
+            thumb_url = ps.get_thumb_url(f["id"],"small")
67
+            data2 = prefix+f["id"]
68
+            #item = ListItem(title, icon=thumb_url, thumbnail=thumb_url, path=data2)
69
+            #item.is_folder = False if type in ("video","photo") else True
70
+
71
+            if type=="album":
72
+                label2 = "%s photos, %s videos"%(f["additional"]["item_count"]["photo"],f["additional"]["item_count"]["video"])
73
+                item = xbmcgui.ListItem(title, label2, thumb_url, thumb_url, path=data2)
74
+                item.setInfo(
75
+                    "Image",
76
+                    {"title":f["info"]["title"],"picturepath":f["additional"]["file_location"]}
77
+                    )
78
+
79
+            elif type == "photo":
80
+                label2 = f["info"]["takendate"]
81
+                image_url = ps.get_thumb_url(oid2,"large")
82
+                data2 = image_url
83
+                item = xbmcgui.ListItem(title, label2, thumb_url, thumb_url, path=data2)
84
+                item.setInfo(
85
+                    "pictures",
86
+                    {"title":f["info"]["title"],
87
+                     "picturepath":f["additional"]["file_location"],
88
+                     "exif:resolution": "720,480" ,}
89
+                    )
90
+
91
+            elif type=="video":
92
+                label2 = f["info"]["takendate"]
93
+                item = xbmcgui.ListItem(title, label2, thumb_url, thumb_url, path=data2)
94
+                item.setInfo(
95
+                    'video', {
96
+                        "title": title,
97
+                        "plot": f["additional"]["file_location"],
98
+                    }
99
+                )
100
+            item.setProperty("is_folder", "true" if is_folder else "")
101
+            item.setProperty("url", data2)
102
+            items.append(item)
103
+            #xbmcplugin.addDirectoryItem(handle=plugin.handle, url=data2, listitem=item, isFolder=is_folder,
104
+                                        #totalItems=len(content["items"]))
105
+        if page_size <> -1 and len(content["items"]) == page_size:
106
+            data2 = prefix+oid+"?page=%s" % (page+1)
107
+            item = xbmcgui.ListItem("Next", "", "", "", data2)
108
+            item.setInfo("Image", {"title":"Next"})
109
+            item.setProperty("is_folder", "false")
110
+            item.setProperty("url", data2)
111
+            items.append(item)
112
+
113
+        sort_methods = [SortMethod.FILE, SortMethod.DATE, SortMethod.DATE_TAKEN]
114
+        view_mode_id =  get_view_mode(view_mode)
115
+        return plugin.finish(items, sort_methods=None, view_mode=view_mode_id, cache_to_disc=True, update_listing=False)
116
+        #xbmcplugin.endOfDirectory(plugin.handle, succeeded=True, updateListing=False, cacheToDisc=True)
117
+
118
+    elif oid.startswith("photo"):
119
+        print "play_photo ", oid
120
+        try:
121
+            url = ps.get_thumb_url(oid,"large")
122
+            #thumb_url = ps.get_thumb_url(oid,"smalll")
123
+            #js = ps.get_photo_info(oid)
124
+            #title = js["info"]["name"]
125
+        except Exception,e:
126
+            plugin.notify(str(e),"",10000)
127
+            return None
128
+        print "set_resolved_url", url
129
+        plugin.set_resolved_url(url)
130
+
131
+    elif oid.startswith("video"):
132
+        try:
133
+            streams = ps.get_video_streams(oid)
134
+            js = ps.get_photo_info(oid)
135
+        except Exception,e:
136
+            plugin.notify(str(e),"",10000)
137
+            return None
138
+        if streams:
139
+            return play_video(streams)
140
+        else:
141
+            plugin.notify("No streams found!",10000)
142
+            return None
143
+
144
+def get_view_mode(vm):
145
+    modes = {
146
+        "skin.estuary": {
147
+            "None": None,
148
+            "List": 50,
149
+            "Poster": 51,
150
+            "IconWall":52 ,
151
+            "Shift": 53,
152
+            "InfoWall": 54,
153
+            "WideList": 55,
154
+            "Wall": 500,
155
+            "Banner": 501,
156
+            "FanArt": 502
157
+        }
158
+    }
159
+    skin = xbmc.getSkinDir()
160
+    if skin in modes and vm in modes[skin]:
161
+        view_mode = modes[skin][vm]
162
+    else:
163
+        view_mode = 50
164
+    return view_mode
165
+
166
+
167
+def play_video(streams):
168
+    stream = streams[0]
169
+    subfiles = []
170
+    print "play_video ",stream["url"]
171
+    item = ListItem(label=stream["name"], thumbnail=stream["img"], path=stream["url"])
172
+    item.set_info("video",{"plot":stream["desc"]})
173
+    item.is_folder = False
174
+    item.set_is_playable(True)
175
+    plugin.play_video(item)
176
+
177
+
178
+if __name__ == '__main__':
179
+    if CLI_MODE:
180
+        from kodiswift.cli.cli import main as start
181
+        start()
182
+    else:
183
+        plugin.run()

+ 17
- 0
addon.xml View File

@@ -0,0 +1,17 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<addon version="0.1.7" id="plugin.image.photostation" name="PhotoStation" provider-name="ivars777"  >
3
+  <requires>
4
+    <import addon="xbmc.python" version="2.1.0"/>
5
+    <import addon="script.module.requests" />
6
+  </requires>
7
+  <extension point="xbmc.python.pluginsource" library="addon.py">
8
+    <provides>image</provides>
9
+  </extension>
10
+  <extension point="xbmc.addon.metadata">
11
+    <platform>all</platform>
12
+    <language></language>
13
+    <summary>Play content from Synology PhotoStation server</summary>
14
+    <description>Play images and video from Synology PhotoStation server
15
+     </description>
16
+  </extension>
17
+</addon>

+ 5
- 0
changelog.md View File

@@ -0,0 +1,5 @@
1
+**0.1.1** (12.02.2017):
2
+- initial exprerimental version
3
+
4
+**0.1.5** (14.02.2016):
5
+- addon settings added

+ 21
- 0
deploy.bat View File

@@ -0,0 +1,21 @@
1
+@echo off
2
+set prog=PhotoStation
3
+set pack_name=plugin.image.photostation
4
+set desc=Play Synology PhotoStation media
5
+set TARGET=C:\Users\User\AppData\Roaming\Kodi\addons\%pack_name%\
6
+
7
+for %%f in (
8
+readme.md
9
+changelog.md
10
+addon.xml
11
+addon.py
12
+icon.png
13
+resources\__init__.py
14
+resources\settings.xml
15
+resources\icon.png
16
+resources\language\English\*
17
+resources\lib\__init__.py
18
+resources\lib\photostation_api.py
19
+) do echo f | xcopy /y  %%f %TARGET%%%f
20
+
21
+pause

+ 2
- 0
get_version.py View File

@@ -0,0 +1,2 @@
1
+import re,sys
2
+print re.search('<addon version="([^"]+)',open(sys.argv[1]).read()).group(1)

+ 72
- 0
kmake.bat View File

@@ -0,0 +1,72 @@
1
+@echo off
2
+:=== Parameters ===
3
+
4
+:--- Pull content submodule ---
5
+rem pushd resources\lib\content
6
+rem git checkout .
7
+rem git pull
8
+rem popd
9
+
10
+python get_version.py addon.xml >version.txt
11
+set /p ver=<version.txt
12
+echo %ver%
13
+pause
14
+
15
+set prog=PhotoStation
16
+set pack_name=plugin.image.photostation
17
+set desc=Play Synology PhotoStation media
18
+
19
+set ipk_dir=ipkg\
20
+set release_dir=release\
21
+set repo_dir=..\repo\
22
+set feed_dir=w:\repo\
23
+
24
+set AR=\MinGW\bin\ar.exe
25
+set TAR=\MinGW\msys\1.0\bin\tar.exe
26
+rem set ZIP=\Program Files (x86)\Gow\bin\zip.exe
27
+
28
+:=== data files ===
29
+if exist "%pack_name%" rm -r -f "%pack_name%"
30
+mkdir "%pack_name%""
31
+if not exist %release_dir% mkdir %release_dir%
32
+if not exist %repo_dir% mkdir %repo_dir%
33
+if not exist  %repo_dir%%pack_name% mkdir  %repo_dir%%pack_name%
34
+if not exist  %pack_name% mkdir %pack_name%
35
+
36
+for %%f in (
37
+readme.md
38
+changelog.md
39
+addon.xml
40
+addon.py
41
+icon.png
42
+kodiswift\*.py
43
+resources\__init__.py
44
+resources\settings.xml
45
+resources\icon.png
46
+resources\language\English\*
47
+resources\lib\__init__.py
48
+resources\lib\photostation_api.py
49
+) do echo f| xcopy %%f %pack_name%\%%f
50
+
51
+if exist  %release_dir%%pack_name%-%ver%.zip rm %release_dir%%pack_name%-%ver%.zip
52
+rem zip  -r %release_dir%%pack_name%-%ver%.zip %pack_name%
53
+"C:\Program Files\WinRAR\winrar.exe" a -afzip -r %release_dir%%pack_name%-%ver%.zip %pack_name%
54
+copy  addon.xml  %repo_dir%%pack_name%\addon.xml /Y
55
+copy  %release_dir%%pack_name%-%ver%.zip  %repo_dir%%pack_name%\%pack_name%-%ver%.zip /Y
56
+python -c "import hashlib; print hashlib.md5(open(r'%repo_dir%%pack_name%\%pack_name%-%ver%.zip','r').read()).hexdigest()" >%repo_dir%%pack_name%\%pack_name%-%ver%.zip.md5
57
+
58
+git add %release_dir%%pack_name%-%ver%.zip
59
+
60
+if not ()==(%1%) (
61
+    git commit -m %ver%
62
+    git tag -d "%ver%"
63
+    git tag %ver%
64
+    git push
65
+
66
+    pushd  %repo_dir%..
67
+    call update_repo.bat
68
+    xcopy /s /y repo %feed_dir%
69
+    popd
70
+)
71
+pause
72
+

+ 83
- 0
kodiswift/__init__.py View File

@@ -0,0 +1,83 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift
4
+----------
5
+
6
+A micro framework to enable rapid development of Kodi plugins.
7
+
8
+:copyright: (c) 2012 by Jonathan Beluch
9
+:license: GPLv3, see LICENSE for more details.
10
+"""
11
+from __future__ import absolute_import
12
+
13
+from types import ModuleType
14
+
15
+try:
16
+    import xbmc
17
+    import xbmcgui
18
+    import xbmcplugin
19
+    import xbmcaddon
20
+    import xbmcvfs
21
+
22
+    CLI_MODE = False
23
+except ImportError:
24
+    CLI_MODE = True
25
+    import sys
26
+    from kodiswift.logger import log
27
+
28
+    # Mock the Kodi modules
29
+    from kodiswift.mockxbmc import xbmc, xbmcgui, xbmcplugin, xbmcaddon, xbmcvfs
30
+
31
+    class _Module(ModuleType):
32
+        """A wrapper class for a module used to override __getattr__.
33
+        This class will behave normally for any existing module attributes.
34
+        For any attributes which do not exist in the wrapped module, a mock
35
+        function will be returned. This function will also return itself
36
+        enabling multiple mock function calls.
37
+        """
38
+
39
+        def __init__(self, wrapped=None):
40
+            self.wrapped = wrapped
41
+            if wrapped:
42
+                self.__dict__.update(wrapped.__dict__)
43
+
44
+        def __getattr__(self, name):
45
+            """Returns any existing attr for the wrapped module or returns a
46
+            mock function for anything else. Never raises an AttributeError.
47
+            """
48
+            try:
49
+                return getattr(self.wrapped, name)
50
+            except AttributeError:
51
+                # noinspection PyUnusedLocal
52
+                # pylint disable=unused-argument
53
+                def func(*args, **kwargs):
54
+                    """A mock function which returns itself, enabling chainable
55
+                    function calls.
56
+                    """
57
+                    log.warning('The %s method has not been implemented on '
58
+                                'the CLI. Your code might not work properly '
59
+                                'when calling it.', name)
60
+                    return self
61
+
62
+                return func
63
+
64
+    xbmc = _Module(xbmc)
65
+    xbmcgui = _Module(xbmcgui)
66
+    xbmcplugin = _Module(xbmcplugin)
67
+    xbmcaddon = _Module(xbmcaddon)
68
+    xbmcvfs = _Module(xbmcvfs)
69
+    for m in (xbmc, xbmcgui, xbmcplugin, xbmcaddon, xbmcvfs):
70
+        name = reversed(m.__name__.rsplit('.', 1)).next()
71
+        sys.modules[name] = m
72
+
73
+from kodiswift.storage import TimedStorage
74
+from kodiswift.request import Request
75
+from kodiswift.common import (kodi_url, clean_dict, pickle_dict, unpickle_args,
76
+                              unpickle_dict, download_page)
77
+from kodiswift.constants import SortMethod
78
+from kodiswift.listitem import ListItem
79
+from kodiswift.logger import setup_log
80
+from kodiswift.module import Module
81
+from kodiswift.urls import AmbiguousUrlException, NotFoundException, UrlRule
82
+from kodiswift.xbmcmixin import XBMCMixin
83
+from kodiswift.plugin import Plugin

+ 37
- 0
kodiswift/actions.py View File

@@ -0,0 +1,37 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.actions
4
+------------------
5
+
6
+This module contains wrapper functions for Kodi built-in functions.
7
+
8
+:copyright: (c) 2012 by Jonathan Beluch
9
+:license: GPLv3, see LICENSE for more details.
10
+"""
11
+
12
+__all__ = ['background', 'update_view']
13
+
14
+
15
+def background(url):
16
+    """This action will run an addon in the background for the provided URL.
17
+
18
+    See 'RunPlugin()' at
19
+    http://kodi.wiki/view/List_of_built-in_functions
20
+
21
+    Args:
22
+        url (str): Full path must be specified.
23
+            Does not work for folder plugins.
24
+
25
+    Returns:
26
+        str: String of the builtin command
27
+    """
28
+    return 'RunPlugin(%s)' % url
29
+
30
+
31
+def update_view(url):
32
+    """This action will update the current container view with provided url.
33
+
34
+    See 'Container.Update()' at
35
+    http://kodi.wiki/view/List_of_built-in_functions
36
+    """
37
+    return 'Container.Update(%s)' % url

+ 18
- 0
kodiswift/cli/__init__.py View File

@@ -0,0 +1,18 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+    kodiswift.cli
4
+    ----------------
5
+
6
+    This package contains modules that are used only when running kodiswift in
7
+    CLI mode. Nothing from this package should be called from addon code.
8
+
9
+    :copyright: (c) 2012 by Jonathan Beluch
10
+    :license: GPLv3, see LICENSE for more details.
11
+"""
12
+
13
+
14
+def Option(*args, **kwargs):
15
+    """Returns a tuple of args, kwargs passed to the function. Useful for
16
+    recording arguments for future function calls.
17
+    """
18
+    return args, kwargs

+ 222
- 0
kodiswift/cli/app.py View File

@@ -0,0 +1,222 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.cli.app
4
+----------------
5
+
6
+This package contains the code which runs plugins from the command line.
7
+
8
+:copyright: (c) 2012 by Jonathan Beluch
9
+:license: GPLv3, see LICENSE for more details.
10
+"""
11
+from __future__ import absolute_import
12
+
13
+import logging
14
+import os
15
+import sys
16
+from xml.etree import ElementTree as Et
17
+
18
+from kodiswift import Plugin, ListItem, logger
19
+from kodiswift.cli import Option
20
+from kodiswift.cli.console import (display_listitems, continue_or_quit,
21
+                                   get_user_choice)
22
+from kodiswift.common import Modes
23
+
24
+__all__ = ['get_addon_module_name', 'crawl', 'RunCommand', 'PluginManager',
25
+           'setup_options', 'patch_sysargv', 'patch_plugin', 'once',
26
+           'interactive']
27
+
28
+
29
+class RunCommand(object):
30
+    """A CLI command to run a plugin."""
31
+
32
+    command = 'run'
33
+    usage = '%prog run [once|interactive|crawl] [url]'
34
+    option_list = (
35
+        Option('-q', '--quiet', action='store_true',
36
+               help='set logging level to quiet'),
37
+        Option('-v', '--verbose', action='store_true',
38
+               help='set logging level to verbose'),
39
+    )
40
+
41
+    @staticmethod
42
+    def run(opts, args):
43
+        """The run method for the 'run' command. Executes a plugin from the
44
+        command line.
45
+        """
46
+        setup_options(opts)
47
+
48
+        mode = Modes.ONCE
49
+        if len(args) > 0 and hasattr(Modes, args[0].upper()):
50
+            _mode = args.pop(0).upper()
51
+            mode = getattr(Modes, _mode)
52
+
53
+        url = None
54
+        if len(args) > 0:
55
+            # A url was specified
56
+            url = args.pop(0)
57
+
58
+        plugin_mgr = PluginManager.load_plugin_from_addon_xml(mode, url)
59
+        plugin_mgr.run()
60
+
61
+
62
+def setup_options(opts):
63
+    """Takes any actions necessary based on command line options"""
64
+    if opts.quiet:
65
+        logger.log.setLevel(logging.WARNING)
66
+        logger.GLOBAL_LOG_LEVEL = logging.WARNING
67
+
68
+    if opts.verbose:
69
+        logger.log.setLevel(logging.DEBUG)
70
+        logger.GLOBAL_LOG_LEVEL = logging.DEBUG
71
+
72
+
73
+def get_addon_module_name(addon_xml_filename):
74
+    """Attempts to extract a module name for the given addon's addon.xml file.
75
+    Looks for the 'xbmc.python.pluginsource' extension node and returns the
76
+    addon's filename without the .py suffix.
77
+    """
78
+    try:
79
+        xml = Et.parse(addon_xml_filename).getroot()
80
+    except IOError:
81
+        sys.exit('Cannot find an addon.xml file in the current working '
82
+                 'directory. Please run this command from the root directory '
83
+                 'of an addon.')
84
+
85
+    try:
86
+        plugin_source = (ext for ext in xml.findall('extension') if
87
+                         ext.get('point') == 'xbmc.python.pluginsource').next()
88
+    except StopIteration:
89
+        sys.exit('ERROR, no pluginsource in addonxml')
90
+
91
+    return plugin_source.get('library').split('.')[0]
92
+
93
+
94
+class PluginManager(object):
95
+    """A class to handle running a plugin in CLI mode. Handles setup state
96
+    before calling plugin.run().
97
+    """
98
+
99
+    @classmethod
100
+    def load_plugin_from_addon_xml(cls, mode, url):
101
+        """Attempts to import a plugin's source code and find an instance of
102
+        :class:`~kodiswift.Plugin`. Returns an instance of PluginManager if
103
+        successful.
104
+        """
105
+        cwd = os.getcwd()
106
+        sys.path.insert(0, cwd)
107
+        module_name = get_addon_module_name(os.path.join(cwd, 'addon.xml'))
108
+        addon = __import__(module_name)
109
+
110
+        # Find the first instance of kodiswift.Plugin
111
+        try:
112
+            plugin = (attr_value for attr_value in vars(addon).values()
113
+                      if isinstance(attr_value, Plugin)).next()
114
+        except StopIteration:
115
+            sys.exit('Could not find a Plugin instance in %s.py' % module_name)
116
+
117
+        return cls(plugin, mode, url)
118
+
119
+    def __init__(self, plugin, mode, url):
120
+        self.plugin = plugin
121
+        self.mode = mode
122
+        self.url = url
123
+
124
+    def run(self):
125
+        """This method runs the the plugin in the appropriate mode parsed from
126
+        the command line options.
127
+        """
128
+        handle = 0
129
+        handlers = {
130
+            Modes.ONCE: once,
131
+            Modes.CRAWL: crawl,
132
+            Modes.INTERACTIVE: interactive,
133
+        }
134
+        handler = handlers[self.mode]
135
+        patch_sysargv(self.url or 'plugin://%s/' % self.plugin.id, handle)
136
+        return handler(self.plugin)
137
+
138
+
139
+def patch_sysargv(*args):
140
+    """Patches sys.argv with the provided args"""
141
+    sys.argv = args[:]
142
+
143
+
144
+def patch_plugin(plugin, path, handle=None):
145
+    """Patches a few attributes of a plugin instance to enable a new call to
146
+    plugin.run()
147
+    """
148
+    if handle is None:
149
+        handle = plugin.request.handle
150
+    patch_sysargv(path, handle)
151
+    plugin._end_of_directory = False
152
+
153
+
154
+def once(plugin, parent_stack=None):
155
+    """A run mode for the CLI that runs the plugin once and exits."""
156
+    plugin.clear_added_items()
157
+    items = plugin.run()
158
+
159
+    # if update_listing=True, we need to remove the last url from the parent
160
+    # stack
161
+    if parent_stack and plugin._update_listing:
162
+        del parent_stack[-1]
163
+
164
+    # if we have parent items, include the most recent in the display
165
+    if parent_stack:
166
+        items.insert(0, parent_stack[-1])
167
+
168
+    display_listitems(items, plugin.request.url)
169
+    return items
170
+
171
+
172
+def interactive(plugin):
173
+    """A run mode for the CLI that runs the plugin in a loop based on user
174
+    input.
175
+    """
176
+    items = [item for item in once(plugin)]
177
+    #items = [item for item in once(plugin) if not item.get_played()]
178
+    parent_stack = []  # Keep track of parents so we can have a '..' option
179
+
180
+    selected_item = get_user_choice(items)
181
+    while selected_item is not None:
182
+        #if parent_stack and selected_item == parent_stack[-1]:
183
+        if selected_item.path.startswith("http"):
184
+            plugin.play_video(selected_item)
185
+            continue
186
+
187
+        if selected_item.label == "..":
188
+            # User selected the parent item, remove from list
189
+            parent_stack.pop()
190
+        else:
191
+            # User selected non parent item, add current url to parent stack
192
+            parent_stack.append(ListItem.from_dict(label='..', path=plugin.request.url))
193
+        patch_plugin(plugin, selected_item.path)
194
+
195
+
196
+        items = [item for item in once(plugin, parent_stack=parent_stack)]
197
+                 #if not item.get_played()]
198
+        selected_item = get_user_choice(items)
199
+
200
+
201
+def crawl(plugin):
202
+    """Performs a breadth-first crawl of all possible routes from the
203
+    starting path. Will only visit a URL once, even if it is referenced
204
+    multiple times in a plugin. Requires user interaction in between each
205
+    fetch.
206
+    """
207
+    # TODO: use OrderedSet?
208
+    paths_visited = set()
209
+    paths_to_visit = set(item.get_path() for item in once(plugin))
210
+
211
+    while paths_to_visit and continue_or_quit():
212
+        path = paths_to_visit.pop()
213
+        paths_visited.add(path)
214
+
215
+        # Run the new listitem
216
+        patch_plugin(plugin, path)
217
+        new_paths = set(item.get_path() for item in once(plugin))
218
+
219
+        # Filter new items by checking against urls_visited and
220
+        # urls_tovisit
221
+        paths_to_visit.update(path for path in new_paths
222
+                              if path not in paths_visited)

+ 78
- 0
kodiswift/cli/cli.py View File

@@ -0,0 +1,78 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.cli.cli
4
+------------------
5
+
6
+The main entry point for the kodiswift console script. CLI commands can be
7
+registered in this module.
8
+
9
+:copyright: (c) 2012 by Jonathan Beluch
10
+:license: GPLv3, see LICENSE for more details.
11
+"""
12
+from __future__ import absolute_import
13
+
14
+import sys
15
+from optparse import OptionParser
16
+
17
+from kodiswift.cli.app import RunCommand
18
+from kodiswift.cli.create import CreateCommand
19
+
20
+# TODO: Make an ABC for Command
21
+COMMANDS = {
22
+    RunCommand.command: RunCommand,
23
+    CreateCommand.command: CreateCommand,
24
+}
25
+
26
+
27
+# TODO: Make this usage dynamic based on COMMANDS dict
28
+USAGE = """%prog <command>
29
+
30
+Commands:
31
+    create
32
+        Create a new plugin project.
33
+
34
+    run
35
+        Run an kodiswift plugin from the command line.
36
+
37
+Help:
38
+    To see options for a command, run `kodiswift <command> -h`
39
+"""
40
+
41
+
42
+def main():
43
+    """The entry point for the console script kodiswift.
44
+
45
+    The 'xbcmswift2' script is command bassed, so the second argument is always
46
+    the command to execute. Each command has its own parser options and usages.
47
+    If no command is provided or the -h flag is used without any other
48
+    commands, the general help message is shown.
49
+    """
50
+    parser = OptionParser()
51
+    if len(sys.argv) == 1:
52
+        parser.set_usage(USAGE)
53
+        parser.error('At least one command is required.')
54
+
55
+    # spy sys.argv[1] in order to use correct opts/args
56
+    command = sys.argv[1]
57
+
58
+    if command == '-h':
59
+        parser.set_usage(USAGE)
60
+        opts, args = parser.parse_args()
61
+
62
+    if command not in COMMANDS.keys():
63
+        parser.error('Invalid command')
64
+
65
+    # We have a proper command, set the usage and options list according to the
66
+    # specific command
67
+    manager = COMMANDS[command]
68
+    if hasattr(manager, 'option_list'):
69
+        for args, kwargs in manager.option_list:
70
+            parser.add_option(*args, **kwargs)
71
+    if hasattr(manager, 'usage'):
72
+        parser.set_usage(manager.usage)
73
+
74
+    opts, args = parser.parse_args()
75
+
76
+    # Since we are calling a specific comamnd's manager, we no longer need the
77
+    # actual command in sys.argv so we slice from position 1
78
+    manager.run(opts, args[1:])

+ 101
- 0
kodiswift/cli/console.py View File

@@ -0,0 +1,101 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+    kodiswift.cli.console
4
+    ----------------------
5
+
6
+    This module contains code to handle CLI interaction.
7
+
8
+    :copyright: (c) 2012 by Jonathan Beluch
9
+    :license: GPLv3, see LICENSE for more details.
10
+"""
11
+from __future__ import print_function, absolute_import
12
+
13
+
14
+def get_max_len(items):
15
+    """Returns the max of the lengths for the provided items"""
16
+    try:
17
+        return max(len(item) for item in items)
18
+    except ValueError:
19
+        return 0
20
+
21
+
22
+def display_listitems(items, url):
23
+    """Displays a list of items along with the index to enable a user
24
+    to select an item.
25
+
26
+    Args:
27
+        items (list[kodiswift.ListItem]):
28
+        url (str):
29
+    """
30
+    if len(items) == 2 and items[0].label == '..' and items[1].played:
31
+        display_video(items)
32
+    else:
33
+        label_width = get_max_len(item.label for item in items)
34
+        num_width = len(str(len(items)))
35
+        output = []
36
+        for i, item in enumerate(items):
37
+            output.append('[%s] %s (%s)' % (
38
+                str(i).rjust(num_width),
39
+                item.label.ljust(label_width),
40
+                item.path))
41
+
42
+        line_width = get_max_len(output)
43
+        output.append('-' * line_width)
44
+
45
+        header = [
46
+            '',
47
+            '=' * line_width,
48
+            'Current URL: %s' % url,
49
+            '-' * line_width,
50
+            '%s %s Path' % ('#'.center(num_width + 2),
51
+                            'Label'.ljust(label_width)),
52
+            '-' * line_width,
53
+        ]
54
+        print('\n'.join(header + output))
55
+
56
+
57
+def display_video(items):
58
+    """Prints a message for a playing video and displays the parent
59
+    listitem.
60
+    """
61
+    parent_item, played_item = items
62
+
63
+    title_line = 'Playing Media %s (%s)' % (played_item.label, played_item.path.encode("utf8"))
64
+    parent_line = '[0] %s (%s)' % (parent_item.label, parent_item.path)
65
+    line_width = get_max_len([title_line, parent_line])
66
+
67
+    output = [
68
+        '-' * line_width,
69
+        title_line,
70
+        '-' * line_width,
71
+        parent_line.encode("utf8") if isinstance(parent_line, unicode) else parent_line ,
72
+    ]
73
+    print('\n'.join(output))
74
+
75
+
76
+def get_user_choice(items):
77
+    """Returns the selected item from provided items or None if 'q' was
78
+    entered for quit.
79
+    """
80
+    choice = raw_input('Choose an item or "q" to quit: ')
81
+    while choice != 'q':
82
+        try:
83
+            item = items[int(choice)]
84
+            print()  # Blank line for readability between interactive views
85
+            return item
86
+        except ValueError:
87
+            # Passed something that couldn't be converted with int()
88
+            choice = raw_input('You entered a non-integer. Choice must be an'
89
+                               ' integer or "q": ')
90
+        except IndexError:
91
+            # Passed an integer that was out of range of the list of urls
92
+            choice = raw_input('You entered an invalid integer. Choice must '
93
+                               'be from above url list or "q": ')
94
+    return None
95
+
96
+
97
+def continue_or_quit():
98
+    """Prints an exit message and returns False if the user wants to
99
+    quit.
100
+    """
101
+    return raw_input('Enter to continue or "q" to quit') != 'q'

+ 192
- 0
kodiswift/cli/create.py View File

@@ -0,0 +1,192 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.cli.create
4
+---------------------
5
+
6
+This module contains the code to initialize a new Kodi addon project.
7
+
8
+:copyright: (c) 2012 by Jonathan Beluch
9
+:license: GPLv3, see LICENSE for more details.
10
+"""
11
+from __future__ import print_function, absolute_import
12
+
13
+import os
14
+import readline
15
+import string
16
+from getpass import getpass
17
+from optparse import OptionParser
18
+from os import getcwd
19
+from shutil import copytree, ignore_patterns
20
+from xml.sax import saxutils
21
+
22
+
23
+class CreateCommand(object):
24
+    """A CLI command to initialize a new Kodi addon project."""
25
+    command = 'create'
26
+    usage = '%prog create'
27
+
28
+    # noinspection PyUnusedLocal
29
+    @staticmethod
30
+    def run(opts, args):
31
+        """Required run function for the 'create' CLI command."""
32
+        create_new_project()
33
+
34
+
35
+# Path to skeleton file templates dir
36
+SKEL = os.path.join(os.path.dirname(__file__), 'data')
37
+
38
+
39
+def error_msg(msg):
40
+    """A decorator that sets the error_message attribute of the decorated
41
+    function to the provided value.
42
+    """
43
+    def decorator(func):
44
+        """Sets the error_message attribute on the provided function"""
45
+        func.error_message = msg
46
+        return func
47
+    return decorator
48
+
49
+
50
+def parse_cli():
51
+    """Currently only one positional arg, create."""
52
+    parser = OptionParser()
53
+    return parser.parse_args()
54
+
55
+
56
+@error_msg('** Value must be non-blank.')
57
+def validate_nonblank(value):
58
+    """A callable that retunrs the value passed"""
59
+    return value
60
+
61
+
62
+@error_msg('** Value must contain only letters or underscores.')
63
+def validate_pluginid(value):
64
+    """Returns True if the provided value is a valid plugin id"""
65
+    valid = string.ascii_letters + string.digits + '.' + '_'
66
+    return all(c in valid for c in value)
67
+
68
+
69
+@error_msg('** The provided path must be an existing folder.')
70
+def validate_isfolder(value):
71
+    """Returns true if the provided path is an existing directory"""
72
+    return os.path.isdir(value)
73
+
74
+
75
+def get_valid_value(prompt, validator, default=None):
76
+    """Displays the provided prompt and gets input from the user. This behavior
77
+    loops indefinitely until the provided validator returns True for the user
78
+    input. If a default value is provided, it will be used only if the user
79
+    hits Enter and does not provide a value.
80
+
81
+    If the validator callable has an error_message attribute, it will be
82
+    displayed for an invalid value, otherwise a generic message is used.
83
+    """
84
+    ans = get_value(prompt, default)
85
+    while not validator(ans):
86
+        try:
87
+            print(validator.error_message)
88
+        except AttributeError:
89
+            print('Invalid value.')
90
+        ans = get_value(prompt, default)
91
+
92
+    return ans
93
+
94
+
95
+def get_value(prompt, default=None, hidden=False):
96
+    """Displays the provided prompt and returns the input from the user. If the
97
+    user hits Enter and there is a default value provided, the default is
98
+    returned.
99
+    """
100
+    _prompt = '%s : ' % prompt
101
+    if default:
102
+        _prompt = '%s [%s]: ' % (prompt, default)
103
+
104
+    if hidden:
105
+        ans = getpass(_prompt)
106
+    else:
107
+        ans = raw_input(_prompt)
108
+
109
+    # If user hit Enter and there is a default value
110
+    if not ans and default:
111
+        ans = default
112
+    return ans
113
+
114
+
115
+def update_file(filename, items):
116
+    """Edits the given file in place, replacing any instances of {key} with the
117
+    appropriate value from the provided items dict. If the given filename ends
118
+    with ".xml" values will be quoted and escaped for XML.
119
+    """
120
+    # TODO: Implement something in the templates to denote whether the value
121
+    # being replaced is an XML attribute or a value. Perhaps move to dyanmic
122
+    # XML tree building rather than string replacement.
123
+    should_escape = filename.endswith('addon.xml')
124
+
125
+    with open(filename, 'r') as inp:
126
+        text = inp.read()
127
+
128
+    for key, val in items.items():
129
+        if should_escape:
130
+            val = saxutils.quoteattr(val)
131
+        text = text.replace('{%s}' % key, val)
132
+    output = text
133
+
134
+    with open(filename, 'w') as out:
135
+        out.write(output)
136
+
137
+
138
+def create_new_project():
139
+    """Creates a new Kodi Plugin directory based on user input"""
140
+    readline.parse_and_bind('tab: complete')
141
+
142
+    print("""
143
+    kodiswift - A micro-framework for creating Kodi plugins.
144
+    xbmc@jonathanbeluch.com
145
+    --
146
+""")
147
+    print('I\'m going to ask you a few questions to get this project started.')
148
+
149
+    opts = {}
150
+
151
+    # Plugin Name
152
+    opts['plugin_name'] = get_valid_value(
153
+        'What is your plugin name?',
154
+        validate_nonblank
155
+    )
156
+
157
+    # Plugin ID
158
+    opts['plugin_id'] = get_valid_value(
159
+        'Enter your plugin id.',
160
+        validate_pluginid,
161
+        'plugin.video.%s' % (opts['plugin_name'].lower().replace(' ', ''))
162
+    )
163
+
164
+    # Parent Directory
165
+    opts['parent_dir'] = get_valid_value(
166
+        'Enter parent folder (where to create project)',
167
+        validate_isfolder,
168
+        getcwd()
169
+    )
170
+
171
+    # Parent Directory
172
+    opts['plugin_dir'] = os.path.join(opts['parent_dir'], opts['plugin_id'])
173
+    assert not os.path.isdir(opts['plugin_dir']), \
174
+        'A folder named %s already exists in %s.' % (opts['plugin_id'],
175
+                                                     opts['parent_dir'])
176
+
177
+    # Provider
178
+    opts['provider_name'] = get_valid_value(
179
+        'Enter provider name',
180
+        validate_nonblank,
181
+    )
182
+
183
+    # Create the project folder by copying over skel
184
+    copytree(SKEL, opts['plugin_dir'], ignore=ignore_patterns('*.pyc'))
185
+
186
+    # Walk through all the new files and fill in with out options
187
+    for root, _, files in os.walk(opts['plugin_dir']):
188
+        for filename in files:
189
+            update_file(os.path.join(root, filename), opts)
190
+
191
+    print('Projects successfully created in %s.' % opts['plugin_dir'])
192
+    print('Done.')

+ 19
- 0
kodiswift/cli/data/addon.py View File

@@ -0,0 +1,19 @@
1
+# -*- coding: utf-8 -*-
2
+from kodiswift import Plugin
3
+
4
+
5
+plugin = Plugin()
6
+
7
+
8
+@plugin.route('/')
9
+def index():
10
+    item = {
11
+        'label': 'Hello Kodi!',
12
+        'path': 'http://example.com/video.mp4',
13
+        'is_playable': True
14
+    }
15
+    return [item]
16
+
17
+
18
+if __name__ == '__main__':
19
+    plugin.run()

+ 17
- 0
kodiswift/cli/data/addon.xml View File

@@ -0,0 +1,17 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<addon id={plugin_id} name={plugin_name} version="0.0.1" provider-name={provider_name}>
3
+  <requires>
4
+    <import addon="xbmc.python" version="2.24.0" />
5
+    <import addon="script.module.kodiswift" version="0.0.1" />
6
+  </requires>
7
+  <extension point="xbmc.python.pluginsource" library="addon.py">
8
+    <provides>video</provides>
9
+  </extension>
10
+  <extension point="xbmc.addon.metadata">
11
+    <platform>all</platform>
12
+    <language>en</language>
13
+    <summary lang="en">Summary for {plugin_name}</summary>
14
+    <description lang="en">Description for {plugin_name}</description>
15
+    <disclaimer lang="en">Disclaimer for {plugin_name}</disclaimer>
16
+  </extension>
17
+</addon>

+ 1
- 0
kodiswift/cli/data/resources/__init__.py View File

@@ -0,0 +1 @@
1
+# -*- coding: utf-8 -*-

+ 26
- 0
kodiswift/cli/data/resources/language/English/strings.po View File

@@ -0,0 +1,26 @@
1
+# Kodi Media Center language file
2
+# Addon Name: {plugin_name}
3
+# Addon id: {plugin_id}
4
+# Addon Provider: {provider_name}
5
+msgid ""
6
+msgstr ""
7
+"Project-Id-Version: Kodi Addons\n"
8
+"Report-Msgid-Bugs-To: \n"
9
+"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n"
10
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11
+"Last-Translator: \n"
12
+"Language-Team: \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
+# strings 30000 thru 30999 reserved for plugins and plugin settings
20
+# strings 31000 thru 31999 reserved for skins
21
+# strings 32000 thru 32999 reserved for scripts
22
+# strings 33000 thru 33999 reserved for common strings used in add-ons
23
+
24
+msgctxt "#33000"
25
+msgid "Hello Kodi"
26
+msgstr ""

+ 1
- 0
kodiswift/cli/data/resources/lib/__init__.py View File

@@ -0,0 +1 @@
1
+# -*- coding: utf-8 -*-

+ 147
- 0
kodiswift/common.py View File

@@ -0,0 +1,147 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.common
4
+-----------------
5
+
6
+This module contains some common helpful functions.
7
+
8
+:copyright: (c) 2012 by Jonathan Beluch
9
+:license: GPLv3, see LICENSE for more details.
10
+"""
11
+from __future__ import absolute_import
12
+
13
+import urllib
14
+
15
+try:
16
+    import cPickle as pickle
17
+except ImportError:
18
+    import pickle
19
+
20
+__all__ = ['clean_dict', 'kodi_url', 'unpickle_args', 'pickle_dict',
21
+           'unpickle_dict', 'download_page', 'Modes']
22
+
23
+
24
+class Modes(object):
25
+    ONCE = 'ONCE'
26
+    CRAWL = 'CRAWL'
27
+    INTERACTIVE = 'INTERACTIVE'
28
+
29
+
30
+def kodi_url(url, **options):
31
+    """Appends key/val pairs to the end of a URL. Useful for passing arbitrary
32
+    HTTP headers to Kodi to be used when fetching a media resource, e.g.
33
+    cookies.
34
+
35
+    Args:
36
+        url (str):
37
+        **options (dict):
38
+
39
+    Returns:
40
+        str:
41
+    """
42
+    options = urllib.urlencode(options)
43
+    if options:
44
+        return url + '|' + options
45
+    return url
46
+
47
+
48
+def clean_dict(data):
49
+    """Remove keys with a value of None
50
+
51
+    Args:
52
+        data (dict):
53
+
54
+    Returns:
55
+        dict:
56
+    """
57
+    return dict((k, v) for k, v in data.items() if v is not None)
58
+
59
+
60
+def pickle_dict(items):
61
+    """Convert `items` values into pickled values.
62
+
63
+    Args:
64
+        items (dict): A dictionary
65
+
66
+    Returns:
67
+        dict: Values which aren't instances of basestring are pickled. Also,
68
+            a new key '_pickled' contains a comma separated list of keys
69
+            corresponding to the pickled values.
70
+    """
71
+    ret = {}
72
+    pickled_keys = []
73
+    for k, v in items.items():
74
+        if isinstance(v, basestring):
75
+            ret[k] = v
76
+        else:
77
+            pickled_keys.append(k)
78
+            ret[k] = pickle.dumps(v)
79
+    if pickled_keys:
80
+        ret['_pickled'] = ','.join(pickled_keys)
81
+    return ret
82
+
83
+
84
+def unpickle_args(items):
85
+    """Takes a dict and un-pickles values whose keys are found in a '_pickled'
86
+    key.
87
+
88
+    >>> unpickle_args({'_pickled': ['foo'], 'foo': ['I3%0A.']})
89
+    {'foo': 3}
90
+
91
+    Args:
92
+        items (dict): A pickled dictionary.
93
+
94
+    Returns:
95
+        dict: Dict with values un-pickled.
96
+    """
97
+    # Technically there can be more than one _pickled value. At this point
98
+    # we'll just use the first one
99
+    pickled = items.pop('_pickled', None)
100
+    if pickled is None:
101
+        return items
102
+
103
+    pickled_keys = pickled[0].split(',')
104
+    ret = {}
105
+    for k, v in items.items():
106
+        if k in pickled_keys:
107
+            ret[k] = [pickle.loads(val) for val in v]
108
+        else:
109
+            ret[k] = v
110
+    return ret
111
+
112
+
113
+def unpickle_dict(items):
114
+    """un-pickles a dictionary that was pickled with `pickle_dict`.
115
+
116
+    Args:
117
+        items (dict): A pickled dictionary.
118
+
119
+    Returns:
120
+        dict: An un-pickled dictionary.
121
+    """
122
+    pickled_keys = items.pop('_pickled', '').split(',')
123
+    ret = {}
124
+    for k, v in items.items():
125
+        if k in pickled_keys:
126
+            ret[k] = pickle.loads(v)
127
+        else:
128
+            ret[k] = v
129
+    return ret
130
+
131
+
132
+def download_page(url, data=None):
133
+    """Returns the response for the given url. The optional data argument is
134
+    passed directly to urlopen.
135
+
136
+    Args:
137
+        url (str): The URL to read.
138
+        data (Optional[any]): If given, a POST request will be made with
139
+            :param:`data` as the POST body.
140
+
141
+    Returns:
142
+        str: The results of requesting the URL.
143
+    """
144
+    conn = urllib.urlopen(url, data)
145
+    resp = conn.read()
146
+    conn.close()
147
+    return resp

+ 76
- 0
kodiswift/constants.py View File

@@ -0,0 +1,76 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.constants
4
+--------------------
5
+
6
+This module contains some helpful constants which ease interaction
7
+with Kodi.
8
+
9
+:copyright: (c) 2012 by Jonathan Beluch
10
+:license: GPLv3, see LICENSE for more details.
11
+"""
12
+from __future__ import absolute_import
13
+
14
+from kodiswift import xbmcplugin
15
+
16
+__all__ = ['SortMethod']
17
+
18
+
19
+class SortMethod(object):
20
+    """Static class to hold all of the available sort methods. The prefix
21
+    of 'SORT_METHOD_' is stripped.
22
+
23
+    e.g. SORT_METHOD_TITLE becomes SortMethod.TITLE
24
+    """
25
+    ALBUM = xbmcplugin.SORT_METHOD_ALBUM
26
+    ALBUM_IGNORE_THE = xbmcplugin.SORT_METHOD_ALBUM_IGNORE_THE
27
+    ARTIST = xbmcplugin.SORT_METHOD_ARTIST
28
+    ARTIST_IGNORE_THE = xbmcplugin.SORT_METHOD_ARTIST_IGNORE_THE
29
+    BITRATE = xbmcplugin.SORT_METHOD_BITRATE
30
+    CHANNEL = xbmcplugin.SORT_METHOD_CHANNEL
31
+    COUNTRY = xbmcplugin.SORT_METHOD_COUNTRY
32
+    DATE = xbmcplugin.SORT_METHOD_DATE
33
+    DATEADDED = xbmcplugin.SORT_METHOD_DATEADDED
34
+    DATE_TAKEN = xbmcplugin.SORT_METHOD_DATE_TAKEN
35
+    DRIVE_TYPE = xbmcplugin.SORT_METHOD_DRIVE_TYPE
36
+    DURATION = xbmcplugin.SORT_METHOD_DURATION
37
+    EPISODE = xbmcplugin.SORT_METHOD_EPISODE
38
+    FILE = xbmcplugin.SORT_METHOD_FILE
39
+    FULLPATH = xbmcplugin.SORT_METHOD_FULLPATH
40
+    GENRE = xbmcplugin.SORT_METHOD_GENRE
41
+    LABEL = xbmcplugin.SORT_METHOD_LABEL
42
+    LABEL_IGNORE_FOLDERS = xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS
43
+    LABEL_IGNORE_THE = xbmcplugin.SORT_METHOD_LABEL_IGNORE_THE
44
+    LASTPLAYED = xbmcplugin.SORT_METHOD_LASTPLAYED
45
+    LISTENERS = xbmcplugin.SORT_METHOD_LISTENERS
46
+    MPAA_RATING = xbmcplugin.SORT_METHOD_MPAA_RATING
47
+    NONE = xbmcplugin.SORT_METHOD_NONE
48
+    PLAYCOUNT = xbmcplugin.SORT_METHOD_PLAYCOUNT
49
+    PLAYLIST_ORDER = xbmcplugin.SORT_METHOD_PLAYLIST_ORDER
50
+    PRODUCTIONCODE = xbmcplugin.SORT_METHOD_PRODUCTIONCODE
51
+    PROGRAM_COUNT = xbmcplugin.SORT_METHOD_PROGRAM_COUNT
52
+    SIZE = xbmcplugin.SORT_METHOD_SIZE
53
+    SONG_RATING = xbmcplugin.SORT_METHOD_SONG_RATING
54
+    STUDIO = xbmcplugin.SORT_METHOD_STUDIO
55
+    STUDIO_IGNORE_THE = xbmcplugin.SORT_METHOD_STUDIO_IGNORE_THE
56
+    TITLE = xbmcplugin.SORT_METHOD_TITLE
57
+    TITLE_IGNORE_THE = xbmcplugin.SORT_METHOD_TITLE_IGNORE_THE
58
+    TRACKNUM = xbmcplugin.SORT_METHOD_TRACKNUM
59
+    UNSORTED = xbmcplugin.SORT_METHOD_UNSORTED
60
+    VIDEO_RATING = xbmcplugin.SORT_METHOD_VIDEO_RATING
61
+    VIDEO_RUNTIME = xbmcplugin.SORT_METHOD_VIDEO_RUNTIME
62
+    VIDEO_SORT_TITLE = xbmcplugin.SORT_METHOD_VIDEO_SORT_TITLE
63
+    VIDEO_SORT_TITLE_IGNORE_THE = xbmcplugin.SORT_METHOD_VIDEO_SORT_TITLE_IGNORE_THE
64
+    VIDEO_TITLE = xbmcplugin.SORT_METHOD_VIDEO_TITLE
65
+    VIDEO_USER_RATING = xbmcplugin.SORT_METHOD_VIDEO_USER_RATING
66
+    VIDEO_YEAR = xbmcplugin.SORT_METHOD_VIDEO_YEAR
67
+
68
+    @classmethod
69
+    def from_string(cls, sort_method):
70
+        """Returns the sort method specified. sort_method is case insensitive.
71
+        Will raise an AttributeError if the provided sort_method does not
72
+        exist.
73
+
74
+        >>> SortMethod.from_string('title')
75
+        """
76
+        return getattr(cls, sort_method.upper())

+ 339
- 0
kodiswift/listitem.py View File

@@ -0,0 +1,339 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.listitem
4
+------------------
5
+
6
+This module contains the ListItem class, which acts as a wrapper
7
+for xbmcgui.ListItem.
8
+
9
+:copyright: (c) 2012 by Jonathan Beluch
10
+:license: GPLv3, see LICENSE for more details.
11
+"""
12
+from __future__ import absolute_import
13
+
14
+import warnings
15
+
16
+from kodiswift import xbmcgui
17
+
18
+__all__ = ['ListItem']
19
+
20
+
21
+class ListItem(object):
22
+    """A wrapper for the xbmcgui.ListItem class. The class keeps track
23
+    of any set properties that xbmcgui doesn't expose getters for.
24
+    """
25
+
26
+    def __init__(self, label=None, label2=None, icon=None, thumbnail=None,
27
+                 path=None):
28
+        """Defaults are an emtpy string since xbmcgui.ListItem will not
29
+        accept None.
30
+        """
31
+        self._listitem = xbmcgui.ListItem(label=label, label2=label2, path=path)
32
+
33
+        # The docs have the thumbnail property set as thumb
34
+        # http://mirrors.kodi.tv/docs/python-docs/16.x-jarvis/xbmcgui.html#ListItem-setArt
35
+        self._art = {'icon': icon, 'thumb': thumbnail}
36
+        self._icon = icon
37
+        self._path = path
38
+        self._thumbnail = thumbnail
39
+        self._context_menu_items = []
40
+        self._played = False
41
+        self._playable = False
42
+        self.is_folder = True
43
+
44
+    def get_context_menu_items(self):
45
+        """Returns the list of currently set context_menu items."""
46
+        return self._context_menu_items
47
+
48
+    def add_context_menu_items(self, items, replace_items=False):
49
+        """Adds context menu items. If replace_items is True all
50
+        previous context menu items will be removed.
51
+        """
52
+        for label, action in items:
53
+            assert isinstance(label, basestring)
54
+            assert isinstance(action, basestring)
55
+        if replace_items:
56
+            self._context_menu_items = []
57
+        self._context_menu_items.extend(items)
58
+        self._listitem.addContextMenuItems(items, replace_items)
59
+
60
+    @property
61
+    def label(self):
62
+        """
63
+        Returns:
64
+            str:
65
+        """
66
+        return self._listitem.getLabel()
67
+
68
+    @label.setter
69
+    def label(self, value):
70
+        """
71
+        Args:
72
+            value (str):
73
+        """
74
+        self._listitem.setLabel(value)
75
+
76
+    def get_label(self):
77
+        warnings.warn('get_label is deprecated, use label property',
78
+                      DeprecationWarning)
79
+        return self.label
80
+
81
+    def set_label(self, value):
82
+        warnings.warn('set_label is deprecated, use label property',
83
+                      DeprecationWarning)
84
+        return self._listitem.setLabel(value)
85
+
86
+    @property
87
+    def label2(self):
88
+        return self._listitem.getLabel2()
89
+
90
+    @label2.setter
91
+    def label2(self, value):
92
+        self._listitem.setLabel2(value)
93
+
94
+    def get_label2(self):
95
+        warnings.warn('get_label2 is deprecated, use label2 property',
96
+                      DeprecationWarning)
97
+        return self.label2
98
+
99
+    def set_label2(self, value):
100
+        warnings.warn('set_label2 is deprecated, use label2 property',
101
+                      DeprecationWarning)
102
+        return self._listitem.setLabel2(value)
103
+
104
+    @property
105
+    def selected(self):
106
+        return self._listitem.isSelected()
107
+
108
+    @selected.setter
109
+    def selected(self, value):
110
+        self._listitem.select(value)
111
+
112
+    def is_selected(self):
113
+        warnings.warn('is_selected is deprecated, use selected property',
114
+                      DeprecationWarning)
115
+        return self._listitem.isSelected()
116
+
117
+    def select(self, selected_status=True):
118
+        warnings.warn('select is deprecated, use selected property',
119
+                      DeprecationWarning)
120
+        return self._listitem.select(selected_status)
121
+
122
+    @property
123
+    def icon(self):
124
+        return self._art.get('icon')
125
+
126
+    @icon.setter
127
+    def icon(self, value):
128
+        self._art['icon'] = value
129
+        self._listitem.setArt(self._art)
130
+
131
+    def get_icon(self):
132
+        warnings.warn('get_icon is deprecated, use icon property',
133
+                      DeprecationWarning)
134
+        return self.icon
135
+
136
+    def set_icon(self, icon):
137
+        warnings.warn('set_icon is deprecated, use icon property',
138
+                      DeprecationWarning)
139
+        self.icon = icon
140
+        return self.icon
141
+
142
+    @property
143
+    def thumbnail(self):
144
+        return self._art.get('thumb')
145
+
146
+    @thumbnail.setter
147
+    def thumbnail(self, value):
148
+        self._art['thumb'] = value
149
+        self._listitem.setArt(self._art)
150
+
151
+    def get_thumbnail(self):
152
+        warnings.warn('get_thumbnail is deprecated, use thumbnail property',
153
+                      DeprecationWarning)
154
+        return self.thumbnail
155
+
156
+    def set_thumbnail(self, thumbnail):
157
+        warnings.warn('set_thumbnail is deprecated, use thumbnail property',
158
+                      DeprecationWarning)
159
+        self.thumbnail = thumbnail
160
+        return self.thumbnail
161
+
162
+    @property
163
+    def poster(self):
164
+        return self._art.get('poster')
165
+
166
+    @poster.setter
167
+    def poster(self, value):
168
+        self._art['poster'] = value
169
+        self._listitem.setArt(self._art)
170
+
171
+    @property
172
+    def path(self):
173
+        return self._path
174
+
175
+    @path.setter
176
+    def path(self, value):
177
+        self._path = value
178
+        self._listitem.setPath(value)
179
+
180
+    def get_path(self):
181
+        warnings.warn('get_path is deprecated, use path property',
182
+                      DeprecationWarning)
183
+        return self._path
184
+
185
+    def set_path(self, path):
186
+        warnings.warn('set_path is deprecated, use path property',
187
+                      DeprecationWarning)
188
+        self._path = path
189
+        return self._listitem.setPath(path)
190
+
191
+    @property
192
+    def playable(self):
193
+        return self._playable
194
+
195
+    @playable.setter
196
+    def playable(self, value):
197
+        self._playable = value
198
+        is_playable = 'true' if self._playable else 'false'
199
+        self.set_property('isPlayable', is_playable)
200
+
201
+    def get_is_playable(self):
202
+        warnings.warn('get_is_playable is deprecated, use playable property',
203
+                      DeprecationWarning)
204
+        return self._playable
205
+
206
+    def set_is_playable(self, is_playable):
207
+        warnings.warn('set_is_playable is deprecated, use playable property',
208
+                      DeprecationWarning)
209
+        self._playable = is_playable
210
+        value = 'false'
211
+        if is_playable:
212
+            value = 'true'
213
+        self.set_property('isPlayable', value)
214
+
215
+    @property
216
+    def played(self):
217
+        return self._played
218
+
219
+    @played.setter
220
+    def played(self, value):
221
+        self._played = value
222
+
223
+    def set_played(self, was_played):
224
+        """Sets the played status of the listitem.
225
+
226
+        Used to differentiate between a resolved video versus a playable item.
227
+        Has no effect on Kodi, it is strictly used for kodiswift.
228
+        """
229
+        warnings.warn('set_played is deprecated, use played property',
230
+                      DeprecationWarning)
231
+        self._played = was_played
232
+
233
+    def get_played(self):
234
+        warnings.warn('get_played is deprecated, use played property',
235
+                      DeprecationWarning)
236
+        return self._played
237
+
238
+    @property
239
+    def art(self):
240
+        return self._art
241
+
242
+    @art.setter
243
+    def art(self, value):
244
+        self._art = value
245
+        self._listitem.setArt(value)
246
+
247
+    def set_art(self, value):
248
+        self._art = value
249
+        self._listitem.setArt(value)
250
+
251
+    def set_info(self, info_type, info_labels):
252
+        """Sets the listitem's info"""
253
+        return self._listitem.setInfo(info_type, info_labels)
254
+
255
+    def get_property(self, key):
256
+        """Returns the property associated with the given key"""
257
+        return self._listitem.getProperty(key)
258
+
259
+    def set_property(self, key, value):
260
+        """Sets a property for the given key and value"""
261
+        return self._listitem.setProperty(key, value)
262
+
263
+    def add_stream_info(self, stream_type, stream_values):
264
+        """Adds stream details"""
265
+        return self._listitem.addStreamInfo(stream_type, stream_values)
266
+
267
+    def as_tuple(self):
268
+        """Returns a tuple of list item properties:
269
+            (path, the wrapped xbmcgui.ListItem, is_folder)
270
+        """
271
+        return self.path, self._listitem, self.is_folder
272
+
273
+    def as_xbmc_listitem(self):
274
+        """Returns the wrapped xbmcgui.ListItem"""
275
+        return self._listitem
276
+
277
+    @classmethod
278
+    def from_dict(cls, label=None, label2=None, icon=None, thumbnail=None,
279
+                  path=None, selected=None, info=None, properties=None,
280
+                  context_menu=None, replace_context_menu=False,
281
+                  is_playable=None, info_type='video', stream_info=None,
282
+                  **kwargs):
283
+        """A ListItem constructor for setting a lot of properties not
284
+        available in the regular __init__ method. Useful to collect all
285
+        the properties in a dict and then use the **dct to call this
286
+        method.
287
+        """
288
+        # TODO(Sinap): Should this just use **kwargs? or should art be a dict?
289
+        listitem = cls(label, label2, path=path)
290
+        listitem.art = {
291
+            'icon': icon,
292
+            'thumb': thumbnail,
293
+            'poster': kwargs.get('poster'),
294
+            'banner': kwargs.get('banner'),
295
+            'fanart': kwargs.get('fanart'),
296
+            'landscape': kwargs.get('landscape'),
297
+        }
298
+
299
+        if selected is not None:
300
+            listitem.selected = selected
301
+
302
+        if info:
303
+            listitem.set_info(info_type, info)
304
+
305
+        if is_playable:
306
+            listitem.playable = True
307
+            listitem.is_folder = False # III
308
+
309
+        if properties:
310
+            # Need to support existing tuples, but prefer to have a dict for
311
+            # properties.
312
+            if hasattr(properties, 'items'):
313
+                properties = properties.items()
314
+            for key, val in properties:
315
+                listitem.set_property(key, val)
316
+
317
+        if stream_info:
318
+            for stream_type, stream_values in stream_info.items():
319
+                listitem.add_stream_info(stream_type, stream_values)
320
+
321
+        if context_menu:
322
+            listitem.add_context_menu_items(context_menu, replace_context_menu)
323
+
324
+        return listitem
325
+
326
+    def __eq__(self, other):
327
+        if not isinstance(other, ListItem):
328
+            raise NotImplementedError
329
+        self_props = (self.label, self.label2, self.art, self.path,
330
+                      self.playable, self.selected, self.played,)
331
+        other_props = (other.label, other.label2, other.art, other.path,
332
+                       other.playable, other.selected, other.played,)
333
+        return self_props == other_props
334
+
335
+    def __str__(self):
336
+        return ('%s (%s)' % (self.label, self.path)).encode('utf-8')
337
+
338
+    def __repr__(self):
339
+        return ("<ListItem '%s'>" % self.label).encode('utf-8')

+ 104
- 0
kodiswift/logger.py View File

@@ -0,0 +1,104 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.log
4
+--------------
5
+
6
+This module contains the kodiswift logger as well as a convenience
7
+method for creating new loggers.
8
+
9
+:copyright: (c) 2012 by Jonathan Beluch
10
+:license: GPLv3, see LICENSE for more details.
11
+"""
12
+from __future__ import absolute_import
13
+
14
+import logging
15
+
16
+from kodiswift import CLI_MODE
17
+
18
+__all__ = ['setup_log', 'GLOBAL_LOG_LEVEL', 'log']
19
+
20
+# TODO: Add logging to a file as well when on CLI with lowest threshold
21
+#       possible
22
+# fh = logging.FileHandler('log_filename.txt')
23
+# fh.setLevel(logging.DEBUG)
24
+# fh.setFormatter(formatter)
25
+# log.addHandler(fh)
26
+# TODO: Allow a global flag to set logging level when dealing with Kodi
27
+# TODO: Add -q and -v flags to CLI to quiet or enable more verbose logging
28
+
29
+
30
+class XBMCFilter(object):
31
+    """A logging filter that streams to STDOUT or to the xbmc log if
32
+    running inside Kodi.
33
+    """
34
+    python_to_xbmc = {
35
+        'DEBUG': 'LOGDEBUG',
36
+        'INFO': 'LOGNOTICE',
37
+        'WARNING': 'LOGWARNING',
38
+        'ERROR': 'LOGERROR',
39
+        'CRITICAL': 'LOGSEVERE',
40
+    }
41
+
42
+    xbmc_levels = {
43
+        'LOGDEBUG': 0,
44
+        'LOGINFO': 1,
45
+        'LOGNOTICE': 2,
46
+        'LOGWARNING': 3,
47
+        'LOGERROR': 4,
48
+        'LOGSEVERE': 5,
49
+        'LOGFATAL': 6,
50
+        'LOGNONE': 7,
51
+    }
52
+
53
+    def __init__(self, prefix):
54
+        self.prefix = prefix
55
+
56
+    def filter(self, record):
57
+        """Returns True for all records if running in the CLI, else returns
58
+        True.
59
+
60
+        When running inside Kodi it calls the xbmc.log() method and prevents
61
+        the message from being double printed to STDOUT.
62
+        """
63
+
64
+        # When running in Kodi, any logged statements will be double printed
65
+        # since we are calling xbmc.log() explicitly. Therefore we return False
66
+        # so every log message is filtered out and not printed again.
67
+        if CLI_MODE:
68
+            return True
69
+        else:
70
+            # Must not be imported until here because of import order issues
71
+            # when running in CLI
72
+            from kodiswift import xbmc
73
+            xbmc_level = XBMCFilter.xbmc_levels.get(
74
+                XBMCFilter.python_to_xbmc.get(record.levelname))
75
+            xbmc.log('%s%s' % (self.prefix, record.getMessage()), xbmc_level)
76
+            return False
77
+
78
+
79
+if CLI_MODE:
80
+    GLOBAL_LOG_LEVEL = logging.INFO
81
+else:
82
+    GLOBAL_LOG_LEVEL = logging.DEBUG
83
+
84
+
85
+def setup_log(name):
86
+    """Returns a logging instance for the provided name. The returned
87
+    object is an instance of logging.Logger. Logged messages will be
88
+    printed to stderr when running in the CLI, or forwarded to Kodi's
89
+    log when running in Kodi mode.
90
+    """
91
+    _log = logging.getLogger(name)
92
+    _log.setLevel(GLOBAL_LOG_LEVEL)
93
+    handler = logging.StreamHandler()
94
+    formatter = logging.Formatter(
95
+        '%(asctime)s - %(levelname)s - [%(name)s] %(message)s')
96
+    handler.setFormatter(formatter)
97
+    _log.addHandler(handler)
98
+    _log.addFilter(XBMCFilter('[%s] ' % name))
99
+    return _log
100
+
101
+
102
+# The kodiswift log
103
+# Plugin writers should use plugin.log instead.
104
+log = setup_log('kodiswift')

+ 1
- 0
kodiswift/mockxbmc/__init__.py View File

@@ -0,0 +1 @@
1
+# -*- coding: utf-8 -*-

+ 1835
- 0
kodiswift/mockxbmc/polib.py
File diff suppressed because it is too large
View File


+ 37
- 0
kodiswift/mockxbmc/utils.py View File

@@ -0,0 +1,37 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import absolute_import
3
+
4
+import os
5
+from xml.dom.minidom import parse
6
+
7
+import kodiswift.mockxbmc.polib as polib
8
+
9
+
10
+def load_addon_strings(addon, filename):
11
+    """This is not an official Kodi method, it is here to facilitate
12
+    mocking up the other methods when running outside of Kodi."""
13
+    def get_strings(fn):
14
+        if os.path.exists(filename):
15
+            po = polib.pofile(fn)
16
+            return {entry.msgctxt[1:]: entry.msgid for entry in po}
17
+        else:
18
+            fn = os.path.splitext(fn)[0] + '.xml'
19
+            xml = parse(fn)
20
+            strings = {tag.getAttribute('id'): tag.firstChild.data
21
+                       for tag in xml.getElementsByTagName('string')}
22
+            return strings
23
+    addon._strings = get_strings(filename)
24
+
25
+
26
+def get_addon_id(addon_xml):
27
+    """Parses an addon id from the given addon.xml filename."""
28
+    xml = parse(addon_xml)
29
+    addon_node = xml.getElementsByTagName('addon')[0]
30
+    return addon_node.getAttribute('id')
31
+
32
+
33
+def get_addon_name(addon_xml):
34
+    """Parses an addon name from the given addon.xml filename."""
35
+    xml = parse(addon_xml)
36
+    addon_node = xml.getElementsByTagName('addon')[0]
37
+    return addon_node.getAttribute('name')

+ 97
- 0
kodiswift/mockxbmc/xbmc.py View File

@@ -0,0 +1,97 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import print_function, absolute_import
3
+
4
+import errno
5
+import os
6
+import tempfile
7
+
8
+import kodiswift
9
+from kodiswift.cli.create import get_value
10
+
11
+TEMP_DIR = os.path.join(tempfile.gettempdir(), 'kodiswift_debug')
12
+kodiswift.log.info('Using temp directory %s', TEMP_DIR)
13
+
14
+
15
+def _create_dir(path):
16
+    """Creates necessary directories for the given path or does nothing
17
+    if the directories already exist.
18
+    """
19
+    try:
20
+        os.makedirs(path)
21
+    except OSError as exc:
22
+        if exc.errno == errno.EEXIST:
23
+            pass
24
+        else:
25
+            raise
26
+
27
+
28
+def log(msg, level=0):
29
+    levels = [
30
+        'LOGDEBUG',
31
+        'LOGINFO',
32
+        'LOGNOTICE',
33
+        'LOGWARNING',
34
+        'LOGERROR',
35
+        'LOGSEVERE',
36
+        'LOGFATAL',
37
+        'LOGNONE',
38
+    ]
39
+    print('%s - %s' % (levels[level], msg))
40
+
41
+
42
+# noinspection PyPep8Naming
43
+def translatePath(path):
44
+    """Creates folders in the OS's temp directory. Doesn't touch any
45
+    possible Kodi installation on the machine. Attempting to do as
46
+    little work as possible to enable this function to work seamlessly.
47
+    """
48
+    valid_dirs = ['xbmc', 'home', 'temp', 'masterprofile', 'profile',
49
+                  'subtitles', 'userdata', 'database', 'thumbnails',
50
+                  'recordings', 'screenshots', 'musicplaylists',
51
+                  'videoplaylists', 'cdrips', 'skin', ]
52
+
53
+    # TODO: Remove asserts
54
+    assert path.startswith('special://'), 'Not a valid special:// path.'
55
+    parts = path.split('/')[2:]
56
+    assert len(parts) > 1, 'Need at least a single root directory'
57
+    assert parts[0] in valid_dirs, '%s is not a valid root dir.' % parts[0]
58
+
59
+    # We don't want to swallow any potential IOErrors here, so only makedir for
60
+    # the root dir, the user is responsible for making any further child dirs
61
+    _create_dir(os.path.join(TEMP_DIR, parts[0]))
62
+
63
+    return os.path.join(TEMP_DIR, *parts)
64
+
65
+
66
+# noinspection PyPep8Naming
67
+class Keyboard(object):
68
+    def __init__(self, default='', heading='', hidden=False):
69
+        self._heading = heading
70
+        self._default = default
71
+        self._hidden = hidden
72
+        self._confirmed = False
73
+        self._input = None
74
+
75
+    def setDefault(self, default):
76
+        self._default = default
77
+
78
+    def setHeading(self, heading):
79
+        self._heading = heading
80
+
81
+    def setHiddenInput(self, hidden):
82
+        self._hidden = hidden
83
+
84
+    def doModal(self):
85
+        self._confirmed = False
86
+        try:
87
+            self._input = get_value(
88
+                self._heading, self._default, hidden=self._hidden)
89
+            self._confirmed = True
90
+        except(KeyboardInterrupt, EOFError):
91
+            pass
92
+
93
+    def isConfirmed(self):
94
+        return self._confirmed
95
+
96
+    def getText(self):
97
+        return self._input

+ 70
- 0
kodiswift/mockxbmc/xbmcaddon.py View File

@@ -0,0 +1,70 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import absolute_import
3
+
4
+import os
5
+
6
+from kodiswift.logger import log
7
+from kodiswift.mockxbmc import utils
8
+
9
+__all__ = ['Addon']
10
+
11
+
12
+def _get_env_setting(name):
13
+    return os.getenv('KODISWIFT_%s' % name.upper())
14
+
15
+
16
+# noinspection PyPep8Naming
17
+class Addon(object):
18
+    def __init__(self, id=None):
19
+        # In CLI mode, kodiswift must be run from the root of the addon
20
+        # directory, so we can rely on getcwd() being correct.
21
+        addon_xml = os.path.join(os.getcwd(), 'addon.xml')
22
+        _id = None
23
+        if os.path.exists(addon_xml):
24
+            _id = utils.get_addon_id(addon_xml)
25
+        self._info = {
26
+            'id': id or _id,
27
+            'name': utils.get_addon_name(addon_xml),
28
+            'profile': 'special://profile/addon_data/%s/' % _id,
29
+            'path': 'special://home/addons/%s' % _id
30
+        }
31
+        self._strings = {}
32
+        self._settings = {}
33
+        strings_fn = os.path.join(
34
+            os.getcwd(), 'resources', 'language', 'English', 'strings.po')
35
+        utils.load_addon_strings(self, strings_fn)
36
+
37
+    def getAddonInfo(self, prop):
38
+        properties = ['author', 'changelog', 'description', 'disclaimer',
39
+                      'fanart', 'icon', 'id', 'name', 'path', 'profile',
40
+                      'stars', 'summary', 'type', 'version']
41
+        if prop not in properties:
42
+            raise ValueError('%s is not a valid property.' % prop)
43
+        return self._info.get(prop, 'Unavailable')
44
+
45
+    def getLocalizedString(self, str_id):
46
+        key = str(str_id)
47
+        if key not in self._strings:
48
+            raise KeyError('id not found in English/strings.po or '
49
+                           'strings.xml.')
50
+        return self._strings[key]
51
+
52
+    def getSetting(self, key):
53
+        log.warning('xbmcaddon.Plugin.getSetting() has not been implemented '
54
+                    'in CLI mode.')
55
+        try:
56
+            value = self._settings[key]
57
+        except KeyError:
58
+            # see if we have an env var
59
+            value = _get_env_setting(key)
60
+            if _get_env_setting(key) is None:
61
+                value = raw_input(
62
+                    '* Please enter a temporary value for %s: ' % key)
63
+            self._settings[key] = value
64
+        return value
65
+
66
+    def setSetting(self, key, value):
67
+        self._settings[key] = value
68
+
69
+    def openSettings(self):
70
+        pass

+ 67
- 0
kodiswift/mockxbmc/xbmcgui.py View File

@@ -0,0 +1,67 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+
4
+# noinspection PyPep8Naming,PyUnusedLocal
5
+class ListItem(object):
6
+    def __init__(self, label=None, label2=None, iconImage=None,
7
+                 thumbnailImage=None, path=None):
8
+        self.label = label
9
+        self.label2 = label2
10
+        self.iconImage = iconImage
11
+        self.thumbnailImage = thumbnailImage
12
+        self.path = path
13
+        self.properties = {}
14
+        self.stream_info = {}
15
+        self.art = {}
16
+        self.selected = False
17
+        self.context_menu_items = None
18
+        self.infolabels = {}
19
+
20
+    def addContextMenuItems(self, items, replaceItems=False):
21
+        self.context_menu_items = items
22
+
23
+    def getLabel(self):
24
+        return self.label
25
+
26
+    def getLabel2(self):
27
+        return self.label2
28
+
29
+    def getProperty(self, key):
30
+        return self.properties[key.lower()]
31
+
32
+    def isSelected(self):
33
+        return self.selected
34
+
35
+    def select(self, selected):
36
+        self.selected = selected
37
+
38
+    def setIconImage(self, icon):
39
+        self.iconImage = icon
40
+
41
+    def setInfo(self, info_type, infoLabels):
42
+        #assert info_type in ['video', 'music', 'pictures']
43
+        self.infolabels.update(infoLabels)
44
+
45
+    def setLabel(self, label):
46
+        self.label = label
47
+
48
+    def setLabel2(self, label2):
49
+        self.label2 = label2
50
+
51
+    def setPath(self, path):
52
+        self.path = path
53
+
54
+    def setProperty(self, key, value):
55
+        self.properties[key.lower()] = value
56
+
57
+    def addStreamInfo(self, stream_type, stream_values):
58
+        self.stream_info.update({stream_type: stream_values})
59
+
60
+    def setThumbnailImage(self, thumb):
61
+        self.thumbnailImage = thumb
62
+
63
+    def setArt(self, values):
64
+        self.art = values
65
+
66
+class WindowXMLDialog:
67
+    pass

+ 88
- 0
kodiswift/mockxbmc/xbmcplugin.py View File

@@ -0,0 +1,88 @@
1
+# coding: utf-8
2
+"""
3
+Functions for Kodi plugins
4
+"""
5
+import sys
6
+
7
+SORT_METHOD_ALBUM = 13
8
+SORT_METHOD_ALBUM_IGNORE_THE = 14
9
+SORT_METHOD_ARTIST = 11
10
+SORT_METHOD_ARTIST_IGNORE_THE = 12
11
+SORT_METHOD_BITRATE = 40
12
+SORT_METHOD_CHANNEL = 38
13
+SORT_METHOD_COUNTRY = 16
14
+SORT_METHOD_DATE = 3
15
+SORT_METHOD_DATEADDED = 19
16
+SORT_METHOD_DATE_TAKEN = 41
17
+SORT_METHOD_DRIVE_TYPE = 6
18
+SORT_METHOD_DURATION = 8
19
+SORT_METHOD_EPISODE = 22
20
+SORT_METHOD_FILE = 5
21
+SORT_METHOD_FULLPATH = 32
22
+SORT_METHOD_GENRE = 15
23
+SORT_METHOD_LABEL = 1
24
+SORT_METHOD_LABEL_IGNORE_FOLDERS = 33
25
+SORT_METHOD_LABEL_IGNORE_THE = 2
26
+SORT_METHOD_LASTPLAYED = 34
27
+SORT_METHOD_LISTENERS = 36
28
+SORT_METHOD_MPAA_RATING = 28
29
+SORT_METHOD_NONE = 0
30
+SORT_METHOD_PLAYCOUNT = 35
31
+SORT_METHOD_PLAYLIST_ORDER = 21
32
+SORT_METHOD_PRODUCTIONCODE = 26
33
+SORT_METHOD_PROGRAM_COUNT = 20
34
+SORT_METHOD_SIZE = 4
35
+SORT_METHOD_SONG_RATING = 27
36
+SORT_METHOD_STUDIO = 30
37
+SORT_METHOD_STUDIO_IGNORE_THE = 31
38
+SORT_METHOD_TITLE = 9
39
+SORT_METHOD_TITLE_IGNORE_THE = 10
40
+SORT_METHOD_TRACKNUM = 7
41
+SORT_METHOD_UNSORTED = 37
42
+SORT_METHOD_VIDEO_RATING = 18
43
+SORT_METHOD_VIDEO_RUNTIME = 29
44
+SORT_METHOD_VIDEO_SORT_TITLE = 24
45
+SORT_METHOD_VIDEO_SORT_TITLE_IGNORE_THE = 25
46
+SORT_METHOD_VIDEO_TITLE = 23
47
+SORT_METHOD_VIDEO_YEAR = 17
48
+
49
+def player(url, title = "", suburl= "",headers={}):
50
+    from subprocess import call
51
+    if not url:
52
+        return
53
+    cmd1 = [r"c:\Program Files\VideoLAN\VLC\vlc.exe",url,
54
+           "--meta-title",title.decode("utf8").encode(sys.getfilesystemencoding()),
55
+           "--http-user-agent","Enigma2"
56
+    ]
57
+    # gst-launch-1.0 -v souphttpsrc ssl-strict=false proxy=127.0.0.1:8888 extra-headers="Origin:adadadasd"  location="http://bitdash-a.akamaihd.net/content/sintel/sintel.mpd" ! decodebin! autovideosink
58
+    cmd2 = [
59
+        r"C:\gstreamer\1.0\x86_64\bin\gst-launch-1.0","-v",
60
+        "playbin", 'uri="%s"'%url,
61
+        #"souphttpsrc", "ssl-strict=false",
62
+        #"proxy=127.0.0.1:8888",
63
+        #'location="%s"'%url,
64
+        #'!decodebin!autovideosink'
65
+    ]
66
+    cmd3 = ["ffplay.exe",url]
67
+    cmd = cmd1 if url.startswith("https") else cmd2
68
+    ret = call(cmd3)
69
+    #if ret:
70
+        #a = raw_input("*** Error, continue")
71
+    return
72
+
73
+def setResolvedUrl(handle, succeeded, listitem):
74
+    """Callback function to tell XBMC that the file plugin has been resolved to a url
75
+
76
+    :param handle: integer - handle the plugin was started with.
77
+    :param succeeded: bool - True=script completed successfully/False=Script did not.
78
+    :param listitem: ListItem - item the file plugin resolved to for playback.
79
+
80
+    Example::
81
+
82
+        xbmcplugin.setResolvedUrl(int(sys.argv[1]), True, listitem)
83
+
84
+    """
85
+    url = listitem.path
86
+    title = listitem.label
87
+    player(url,title)
88
+    pass

+ 25
- 0
kodiswift/mockxbmc/xbmcvfs.py View File

@@ -0,0 +1,25 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+import os
4
+
5
+
6
+def exists(target):
7
+    return os.path.exists(target)
8
+
9
+
10
+def rename(origin, target):
11
+    return os.rename(origin, target)
12
+
13
+
14
+def delete(target):
15
+    if os.path.isfile(target) and not os.path.isdir(target):
16
+        return os.unlink(target)
17
+    return False
18
+
19
+
20
+def mkdir(target):
21
+    os.mkdir(target)
22
+
23
+
24
+def listdir(target):
25
+    return os.listdir(target)

+ 155
- 0
kodiswift/module.py View File

@@ -0,0 +1,155 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.module
4
+-----------------
5
+
6
+This module contains the Module Class.
7
+
8
+:copyright: (c) 2012 by Jonathan Beluch
9
+:license: GPLv3, see LICENSE for more details.
10
+"""
11
+from __future__ import absolute_import
12
+
13
+from kodiswift import setup_log
14
+from kodiswift.xbmcmixin import XBMCMixin
15
+
16
+__all__ = ['Module']
17
+
18
+
19
+class Module(XBMCMixin):
20
+    """Modules are basically mini plugins except they don't have any
21
+    functionality until they are registered with a Plugin.
22
+    """
23
+
24
+    def __init__(self, namespace):
25
+        # Get rid of package prefixes
26
+        self._namespace = namespace.split('.')[-1]
27
+        self._view_functions = {}
28
+        self._routes = []
29
+        self._register_funcs = []
30
+        self._plugin = None
31
+        self._url_prefix = None
32
+        self._log = setup_log(namespace)
33
+
34
+    @property
35
+    def plugin(self):
36
+        """Returns the plugin this module is registered to, or
37
+
38
+        Returns:
39
+            kodiswift.Plugin:
40
+
41
+        Raises:
42
+            RuntimeError: If not registered
43
+        """
44
+        if self._plugin is None:
45
+            raise RuntimeError('Module must be registered in order to call'
46
+                               'this method.')
47
+        return self._plugin
48
+
49
+    @plugin.setter
50
+    def plugin(self, value):
51
+        self._plugin = value
52
+
53
+    @property
54
+    def cache_path(self):
55
+        """Returns the module's cache_path."""
56
+        return self.plugin.storage_path
57
+
58
+    @property
59
+    def addon(self):
60
+        """Returns the module's addon"""
61
+        return self.plugin.addon
62
+
63
+    @property
64
+    def added_items(self):
65
+        """Returns this module's added_items"""
66
+        return self.plugin.added_items
67
+
68
+    @property
69
+    def handle(self):
70
+        """Returns this module's handle"""
71
+        return self.plugin.handle
72
+
73
+    @property
74
+    def request(self):
75
+        """Returns the current request"""
76
+        return self.plugin.request
77
+
78
+    @property
79
+    def log(self):
80
+        """Returns the registered plugin's log."""
81
+        return self._log
82
+
83
+    @property
84
+    def url_prefix(self):
85
+        """Sets or gets the url prefix of the module.
86
+
87
+        Raises an Exception if this module is not registered with a
88
+        Plugin.
89
+
90
+        Returns:
91
+            str:
92
+
93
+        Raises:
94
+            RuntimeError:
95
+        """
96
+        if self._url_prefix is None:
97
+            raise RuntimeError(
98
+                'Module must be registered in order to call this method.')
99
+        return self._url_prefix
100
+
101
+    @url_prefix.setter
102
+    def url_prefix(self, value):
103
+        self._url_prefix = value
104
+
105
+    @property
106
+    def register_funcs(self):
107
+        return self._register_funcs
108
+
109
+    def route(self, url_rule, name=None, options=None):
110
+        """A decorator to add a route to a view. name is used to
111
+        differentiate when there are multiple routes for a given view."""
112
+        def decorator(func):
113
+            """Adds a url rule for the provided function"""
114
+            view_name = name or func.__name__
115
+            self.add_url_rule(url_rule, func, name=view_name, options=options)
116
+            return func
117
+        return decorator
118
+
119
+    def url_for(self, endpoint, explicit=False, **items):
120
+        """Returns a valid Kodi plugin URL for the given endpoint name.
121
+        endpoint can be the literal name of a function, or it can
122
+        correspond to the name keyword arguments passed to the route
123
+        decorator.
124
+
125
+        Currently, view names must be unique across all plugins and
126
+        modules. There are not namespace prefixes for modules.
127
+        """
128
+        # TODO: Enable items to be passed with keywords of other var names
129
+        #       such as endpoing and explicit
130
+        # TODO: Figure out how to handle the case where a module wants to
131
+        #       call a parent plugin view.
132
+        if not explicit and not endpoint.startswith(self._namespace):
133
+            endpoint = '%s.%s' % (self._namespace, endpoint)
134
+        return self._plugin.url_for(endpoint, **items)
135
+
136
+    def add_url_rule(self, url_rule, view_func, name, options=None):
137
+        """This method adds a URL rule for routing purposes. The
138
+        provided name can be different from the view function name if
139
+        desired. The provided name is what is used in url_for to build
140
+        a URL.
141
+
142
+        The route decorator provides the same functionality.
143
+        """
144
+        name = '%s.%s' % (self._namespace, name)
145
+
146
+        def register_rule(plugin, url_prefix):
147
+            """Registers a url rule for the provided plugin and
148
+            url_prefix.
149
+            """
150
+            full_url_rule = url_prefix + url_rule
151
+            plugin.add_url_rule(full_url_rule, view_func, name, options)
152
+
153
+        # Delay actual registration of the url rule until this module is
154
+        # registered with a plugin
155
+        self._register_funcs.append(register_rule)

+ 357
- 0
kodiswift/plugin.py View File

@@ -0,0 +1,357 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.plugin
4
+-----------------
5
+
6
+This module contains the Plugin class. This class handles all of the url
7
+routing and interaction with Kodi for a plugin.
8
+
9
+:copyright: (c) 2012 by Jonathan Beluch
10
+:license: GPLv3, see LICENSE for more details.
11
+"""
12
+from __future__ import absolute_import
13
+
14
+import collections
15
+import inspect
16
+import os
17
+import sys
18
+
19
+import kodiswift
20
+from kodiswift import xbmc, xbmcaddon, Request
21
+from kodiswift.logger import log, setup_log
22
+from kodiswift.urls import UrlRule, NotFoundException, AmbiguousUrlException
23
+from kodiswift.xbmcmixin import XBMCMixin
24
+
25
+__all__ = ['Plugin']
26
+
27
+
28
+class Plugin(XBMCMixin):
29
+    """The Plugin objects encapsulates all the properties and methods necessary
30
+    for running an Kodi plugin. The plugin instance is a central place for
31
+    registering view functions and keeping track of plugin state.
32
+
33
+    Usually the plugin instance is created in the main plugin.py file for the
34
+    plugin. Typical creation looks like this::
35
+
36
+        >>> from kodiswift import Plugin
37
+        >>> plugin = Plugin('Hello Kodi')
38
+    """
39
+
40
+    def __init__(self, name=None, addon_id=None, plugin_file=None,
41
+                 info_type=None):
42
+        """
43
+        Args:
44
+            name (Optional[str]): The name of the plugin, e.g. 'Hello Kodi'.
45
+            addon_id (Optional[str): The Kodi addon ID for the plugin,
46
+                e.g. 'plugin.video.hellokodi'. This parameter is now optional
47
+                and is really only useful for testing purposes. If it is not
48
+                provided, the correct value will be parsed from the
49
+                addon.xml file.
50
+            plugin_file (Optional[str]): If provided, it should be the path
51
+                to the plugin.py file in the root of the addon directory.
52
+                This only has an effect when kodiswift is running on the
53
+                command line. Will default to the current working directory
54
+                since kodiswift requires execution in the root addon directory
55
+                anyway. The parameter still exists to ease testing.
56
+            info_type (Optional[str):
57
+        """
58
+        self._name = name
59
+        self._routes = []
60
+        self._view_functions = {}
61
+        self._addon = xbmcaddon.Addon()
62
+
63
+        self._addon_id = addon_id or self._addon.getAddonInfo('id')
64
+        self._name = name or self._addon.getAddonInfo('name')
65
+
66
+        self._info_type = info_type
67
+        if not self._info_type:
68
+            types = {
69
+                'video': 'video',
70
+                'audio': 'music',
71
+                'image': 'pictures',
72
+            }
73
+            self._info_type = types.get(self._addon_id.split('.')[1], 'video')
74
+
75
+        # Keeps track of the added list items
76
+        self._current_items = []
77
+
78
+        # Gets initialized when self.run() is called
79
+        self._request = None
80
+
81
+        # A flag to keep track of a call to xbmcplugin.endOfDirectory()
82
+        self._end_of_directory = False
83
+
84
+        # Keep track of the update_listing flag passed to
85
+        # xbmcplugin.endOfDirectory()
86
+        self._update_listing = False
87
+
88
+        # The plugin's named logger
89
+        self._log = setup_log(self._addon_id)
90
+
91
+        # The path to the storage directory for the addon
92
+        self._storage_path = xbmc.translatePath(
93
+            'special://profile/addon_data/%s/.storage/' % self._addon_id)
94
+        if not os.path.isdir(self._storage_path):
95
+            os.makedirs(self._storage_path)
96
+
97
+        # If we are running in CLI, we need to load the strings.xml manually
98
+        # Since kodiswift currently relies on execution from an addon's root
99
+        # directly, we can rely on cwd for now...
100
+        if kodiswift.CLI_MODE:
101
+            from kodiswift.mockxbmc import utils
102
+            if plugin_file:
103
+                plugin_dir = os.path.dirname(plugin_file)
104
+            else:
105
+                plugin_dir = os.getcwd()
106
+            strings_fn = os.path.join(
107
+                plugin_dir, 'resources', 'language', 'English', 'strings.po')
108
+            utils.load_addon_strings(self._addon, strings_fn)
109
+            self.load_addon_settings()
110
+
111
+    @property
112
+    def info_type(self):
113
+        return self._info_type
114
+
115
+    @property
116
+    def log(self):
117
+        """The log instance for the plugin.
118
+
119
+        Returns an instance of the stdlib's ``logging.Logger``.
120
+        This log will print to STDOUT when running in CLI mode and will
121
+        forward messages to Kodi's log when running in Kodi.
122
+
123
+        Examples:
124
+            ``plugin.log.debug('Debug message')``
125
+            ``plugin.log.warning('Warning message')``
126
+            ``plugin.log.error('Error message')``
127
+
128
+        Returns:
129
+            logging.Logger:
130
+        """
131
+        return self._log
132
+
133
+    @property
134
+    def id(self):
135
+        """The id for the addon instance.
136
+        """
137
+        return self._addon_id
138
+
139
+    @property
140
+    def storage_path(self):
141
+        """A full path to the storage folder for this plugin's addon data.
142
+        """
143
+        return self._storage_path
144
+
145
+    @property
146
+    def addon(self):
147
+        """This addon's wrapped instance of xbmcaddon.Plugin.
148
+        """
149
+        return self._addon
150
+
151
+    @property
152
+    def added_items(self):
153
+        """The list of currently added items.
154
+
155
+        Even after repeated calls to :meth:`~kodiswift.Plugin.add_items`, this
156
+        property will contain the complete list of added items.
157
+        """
158
+        return self._current_items
159
+
160
+    @property
161
+    def handle(self):
162
+        """The current plugin's handle. Equal to ``plugin.request.handle``.
163
+        """
164
+        return self.request.handle
165
+
166
+    @property
167
+    def request(self):
168
+        """The current :class:`~kodiswift.Request`.
169
+
170
+        Raises:
171
+            Exception: if the request hasn't been initialized yet via
172
+                :meth:`~kodiswift.Plugin.run()`.
173
+
174
+        Returns:
175
+            kodiswift.Request:
176
+        """
177
+        if self._request is None:
178
+            raise Exception('It seems the current request has not been '
179
+                            'initialized yet. Please ensure that '
180
+                            '`plugin.run()` has been called before attempting '
181
+                            'to access the current request.')
182
+        return self._request
183
+
184
+    @property
185
+    def name(self):
186
+        """The addon's name.
187
+
188
+        Returns:
189
+            str:
190
+        """
191
+        return self._name
192
+
193
+    def clear_added_items(self):
194
+        self._current_items = []
195
+
196
+    def register_module(self, module, url_prefix):
197
+        """Registers a module with a plugin. Requires a url_prefix that will
198
+        then enable calls to url_for.
199
+
200
+        Args:
201
+            module (kodiswift.Module):
202
+            url_prefix (str): A url prefix to use for all module urls,
203
+                e.g. '/mymodule'
204
+        """
205
+        module.plugin = self
206
+        module.url_prefix = url_prefix
207
+        for func in module.register_funcs:
208
+            func(self, url_prefix)
209
+
210
+    def cached_route(self, url_rule, name=None, options=None, ttl=None):
211
+        """A decorator to add a route to a view and also apply caching. The
212
+        url_rule, name and options arguments are the same arguments for the
213
+        route function. The TTL argument if given will passed along to the
214
+        caching decorator.
215
+        """
216
+        route_decorator = self.route(url_rule, name=name, options=options)
217
+        if ttl:
218
+            cache_decorator = self.cached(ttl)
219
+        else:
220
+            cache_decorator = self.cached()
221
+
222
+        def new_decorator(func):
223
+            return route_decorator(cache_decorator(func))
224
+
225
+        return new_decorator
226
+
227
+    def route(self, url_rule=None, name=None, root=False, options=None):
228
+        """A decorator to add a route to a view. name is used to
229
+        differentiate when there are multiple routes for a given view."""
230
+
231
+        def decorator(f):
232
+            view_name = name or f.__name__
233
+            if root:
234
+                url = '/'
235
+            elif not url_rule:
236
+                url = '/' + view_name + '/'
237
+                args = inspect.getargspec(f)[0]
238
+                if args:
239
+                    url += '/'.join('%s/<%s>' % (p, p) for p in args)
240
+            else:
241
+                url = url_rule
242
+            self.add_url_rule(url, f, name=view_name, options=options)
243
+            return f
244
+
245
+        return decorator
246
+
247
+    def add_url_rule(self, url_rule, view_func, name, options=None):
248
+        """This method adds a URL rule for routing purposes. The
249
+        provided name can be different from the view function name if
250
+        desired. The provided name is what is used in url_for to build
251
+        a URL.
252
+
253
+        The route decorator provides the same functionality.
254
+        """
255
+        rule = UrlRule(url_rule, view_func, name, options)
256
+        if name in self._view_functions.keys():
257
+            # TODO: Raise exception for ambiguous views during registration
258
+            log.warning('Cannot add url rule "%s" with name "%s". There is '
259
+                        'already a view with that name', url_rule, name)
260
+            self._view_functions[name] = None
261
+        else:
262
+            log.debug('Adding url rule "%s" named "%s" pointing to function '
263
+                      '"%s"', url_rule, name, view_func.__name__)
264
+            self._view_functions[name] = rule
265
+        self._routes.append(rule)
266
+
267
+    def url_for(self, endpoint, **items):
268
+        """Returns a valid Kodi plugin URL for the given endpoint name.
269
+        endpoint can be the literal name of a function, or it can
270
+        correspond to the name keyword arguments passed to the route
271
+        decorator.
272
+
273
+        Raises AmbiguousUrlException if there is more than one possible
274
+        view for the given endpoint name.
275
+        """
276
+        try:
277
+            rule = self._view_functions[endpoint]
278
+        except KeyError:
279
+            try:
280
+                rule = (rule for rule in self._view_functions.values()
281
+                        if rule.view_func == endpoint).next()
282
+            except StopIteration:
283
+                raise NotFoundException(
284
+                    '%s does not match any known patterns.' % endpoint)
285
+
286
+        # rule can be None since values of None are allowed in the
287
+        # _view_functions dict. This signifies more than one view function is
288
+        # tied to the same name.
289
+        if not rule:
290
+            # TODO: Make this a regular exception
291
+            raise AmbiguousUrlException
292
+
293
+        path_qs = rule.make_path_qs(items)
294
+        return 'plugin://%s%s' % (self._addon_id, path_qs)
295
+
296
+    def redirect(self, url):
297
+        """Used when you need to redirect to another view, and you only
298
+        have the final plugin:// url."""
299
+        # TODO: Should we be overriding self.request with the new request?
300
+        new_request = self._parse_request(url=url, handle=self.request.handle)
301
+        log.debug('Redirecting %s to %s', self.request.path, new_request.path)
302
+        return self._dispatch(new_request.path)
303
+
304
+    def run(self):
305
+        """The main entry point for a plugin."""
306
+        self._request = self._parse_request()
307
+        log.debug('Handling incoming request for %s', self.request.path)
308
+        items = self._dispatch(self.request.path)
309
+
310
+        # Close any open storages which will persist them to disk
311
+        if hasattr(self, '_unsynced_storage'):
312
+            for storage in self._unsynced_storage.values():
313
+                log.debug('Saving a %s storage to disk at "%s"',
314
+                          storage.file_format, storage.file_path)
315
+                storage.close()
316
+
317
+        return items
318
+
319
+    def _dispatch(self, path):
320
+        for rule in self._routes:
321
+            try:
322
+                view_func, items = rule.match(path)
323
+            except NotFoundException:
324
+                continue
325
+            log.info('Request for "%s" matches rule for function "%s"',
326
+                     path, view_func.__name__)
327
+            resp = view_func(**items)
328
+
329
+            # Only call self.finish() for UI container listing calls to plugin
330
+            # (handle will be >= 0). Do not call self.finish() when called via
331
+            # RunPlugin() (handle will be -1).
332
+            if not self._end_of_directory and self.handle >= 0:
333
+                if isinstance(resp, dict):
334
+                    resp['items'] = self.finish(**resp)
335
+                elif isinstance(resp, collections.Iterable):
336
+                    resp = self.finish(items=resp)
337
+            return resp
338
+
339
+        raise NotFoundException('No matching view found for %s' % path)
340
+
341
+    @staticmethod
342
+    def _parse_request(url=None, handle=None):
343
+        """Handles setup of the plugin state, including request
344
+        arguments, handle, mode.
345
+
346
+        This method never needs to be called directly. For testing, see
347
+        plugin.test()
348
+        """
349
+        # To accommodate self.redirect, we need to be able to parse a full
350
+        # url as well
351
+        if url is None:
352
+            url = sys.argv[0]
353
+            if len(sys.argv) == 3:
354
+                url += sys.argv[2]
355
+        if handle is None:
356
+            handle = sys.argv[1]
357
+        return Request(url, handle)

+ 46
- 0
kodiswift/request.py View File

@@ -0,0 +1,46 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.request
4
+------------------
5
+
6
+This module contains the Request class. This class represents an incoming
7
+request from Kodi.
8
+
9
+:copyright: (c) 2012 by Jonathan Beluch
10
+:license: GPLv3, see LICENSE for more details.
11
+"""
12
+from __future__ import absolute_import
13
+
14
+import urlparse
15
+
16
+from kodiswift.common import unpickle_args
17
+
18
+__all__ = ['Request']
19
+
20
+
21
+class Request(object):
22
+
23
+    def __init__(self, url, handle):
24
+        """The request objects contains all the arguments passed to the plugin via
25
+        the command line.
26
+
27
+        Args:
28
+            url (str): The complete plugin URL being requested. Since Kodi
29
+                typically passes the URL query string in a separate argument
30
+                from the base URL, they must be joined into a single string
31
+                before being provided.
32
+            handle (Union[int, str]): The handle associated with the current
33
+                request.
34
+        """
35
+        self.url = url
36
+
37
+        #: The current request's handle, an integer.
38
+        self.handle = int(handle)
39
+
40
+        # urlparse doesn't like the 'plugin' scheme, so pass a protocol
41
+        # relative url, e.g. //plugin.video.helloxbmc/path
42
+        self.scheme, remainder = url.split(':', 1)
43
+        parts = urlparse.urlparse(remainder)
44
+        self.netloc, self.path, self.query_string = (
45
+            parts[1], parts[2], parts[4])
46
+        self.args = unpickle_args(urlparse.parse_qs(self.query_string))

+ 163
- 0
kodiswift/storage.py View File

@@ -0,0 +1,163 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.storage
4
+-----------------
5
+
6
+This module contains persistent storage classes.
7
+
8
+:copyright: (c) 2012 by Jonathan Beluch
9
+:license: GPLv3, see LICENSE for more details.
10
+"""
11
+from __future__ import absolute_import
12
+
13
+import collections
14
+import json
15
+import os
16
+import time
17
+import shutil
18
+from datetime import datetime
19
+
20
+try:
21
+    import cPickle as pickle
22
+except ImportError:
23
+    import pickle
24
+
25
+__all__ = ['Formats', 'PersistentStorage', 'TimedStorage', 'UnknownFormat']
26
+
27
+
28
+class UnknownFormat(Exception):
29
+    pass
30
+
31
+
32
+class Formats(object):
33
+    PICKLE = 'pickle'
34
+    JSON = 'json'
35
+
36
+
37
+class PersistentStorage(collections.MutableMapping):
38
+    def __init__(self, file_path, file_format=Formats.PICKLE):
39
+        """
40
+        Args:
41
+            file_path (str):
42
+            file_format (Optional[kodiswift.Formats]):
43
+        """
44
+        super(PersistentStorage, self).__init__()
45
+        self.file_path = file_path
46
+        self.file_format = file_format
47
+        self._store = {}
48
+        self._loaded = False
49
+
50
+    def __getitem__(self, key):
51
+        return self._store[key]
52
+
53
+    def __setitem__(self, key, value):
54
+        self._store[key] = value
55
+
56
+    def __delitem__(self, key):
57
+        del self._store[key]
58
+
59
+    def __iter__(self):
60
+        return iter(self._store)
61
+
62
+    def __len__(self):
63
+        return len(self._store)
64
+
65
+    def __enter__(self):
66
+        self.load()
67
+        self.sync()
68
+        return self
69
+
70
+    def __exit__(self, exc_type, exc_val, exc_tb):
71
+        self.sync()
72
+
73
+    def __repr__(self):
74
+        return '%s(%r)' % (self.__class__.__name__, self._store)
75
+
76
+    def items(self):
77
+        return self._store.items()
78
+
79
+    def load(self):
80
+        """Load the file from disk.
81
+
82
+        Returns:
83
+            bool: True if successfully loaded, False if the file
84
+                doesn't exist.
85
+
86
+        Raises:
87
+            UnknownFormat: When the file exists but couldn't be loaded.
88
+        """
89
+
90
+        if not self._loaded and os.path.exists(self.file_path):
91
+            with open(self.file_path, 'rb') as f:
92
+                for loader in (pickle.load, json.load):
93
+                    try:
94
+                        f.seek(0)
95
+                        self._store = loader(f)
96
+                        self._loaded = True
97
+                        break
98
+                    except pickle.UnpicklingError:
99
+                        pass
100
+            # If the file exists and wasn't able to be loaded, raise an error.
101
+            if not self._loaded:
102
+                raise UnknownFormat('Failed to load file')
103
+        return self._loaded
104
+
105
+    def close(self):
106
+        self.sync()
107
+
108
+    def sync(self):
109
+        temp_file = self.file_path + '.tmp'
110
+        try:
111
+            with open(temp_file, 'wb') as f:
112
+                if self.file_format == Formats.PICKLE:
113
+                    pickle.dump(self._store, f, 2)
114
+                elif self.file_format == Formats.JSON:
115
+                    json.dump(self._store, f, separators=(',', ':'))
116
+                else:
117
+                    raise NotImplementedError(
118
+                        'Unknown file format ' + repr(self.file_format))
119
+        except Exception:
120
+            if os.path.exists(temp_file):
121
+                os.remove(temp_file)
122
+            raise
123
+        shutil.move(temp_file, self.file_path)
124
+
125
+
126
+class TimedStorage(PersistentStorage):
127
+    """A dict with the ability to persist to disk and TTL for items."""
128
+
129
+    def __init__(self, file_path, ttl=None, **kwargs):
130
+        """
131
+        Args:
132
+            file_path (str):
133
+            ttl (Optional[int]):
134
+        """
135
+        super(TimedStorage, self).__init__(file_path, **kwargs)
136
+        self.ttl = ttl
137
+
138
+    def __setitem__(self, key, value):
139
+        self._store[key] = (value, time.time())
140
+
141
+    def __getitem__(self, item):
142
+        val, timestamp = self._store[item]
143
+        ttl_diff = datetime.utcnow() - datetime.utcfromtimestamp(timestamp)
144
+        if self.ttl and ttl_diff > self.ttl:
145
+            del self._store[item]
146
+            raise KeyError
147
+        return val
148
+
149
+    def __repr__(self):
150
+        return '%s(%r)' % (self.__class__.__name__,
151
+                           dict((k, v[0]) for k, v in self._store.items()))
152
+
153
+    def items(self):
154
+        items = []
155
+        for k in self._store.keys():
156
+            try:
157
+                items.append((k, self[k]))
158
+            except KeyError:
159
+                pass
160
+        return items
161
+
162
+    def sync(self):
163
+        super(TimedStorage, self).sync()

+ 212
- 0
kodiswift/urls.py View File

@@ -0,0 +1,212 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+kodiswift.urls
4
+---------------
5
+
6
+This module contains URLRule class for dealing with url patterns.
7
+
8
+:copyright: (c) 2012 by Jonathan Beluch
9
+:license: GPLv3, see LICENSE for more details.
10
+"""
11
+from __future__ import absolute_import
12
+
13
+import re
14
+from urllib import urlencode, unquote_plus, quote_plus
15
+
16
+from kodiswift.common import pickle_dict, unpickle_dict
17
+
18
+__all__ = ['UrlRule', 'AmbiguousUrlException', 'NotFoundException']
19
+
20
+
21
+class AmbiguousUrlException(Exception):
22
+    pass
23
+
24
+
25
+class NotFoundException(Exception):
26
+    pass
27
+
28
+
29
+class UrlRule(object):
30
+    """A class the encapsulates a URL
31
+    """
32
+
33
+    def __init__(self, url_rule, view_func, name, options):
34
+        """Stores the various properties related to a routing URL rule.
35
+
36
+        It also provides a few methods to create URLs from the rule or to
37
+        match a given URL against a rule.
38
+
39
+        Args:
40
+            url_rule: The relative url pattern for the rule.
41
+                It may include <var_name> to denote where dynamic variables
42
+                should be matched.
43
+            view_func: The function that should be bound to this rule.
44
+                This should be an actual function object.
45
+            name: The name of the url rule. This is used in the reverse
46
+                process of creating urls for a given rule.
47
+            options: A dict containing any default values for the url rule.
48
+
49
+        Warnings:
50
+            view_func: The function signature should match any variable
51
+                names in the provided url_rule.
52
+        """
53
+        self._name = name
54
+        self._url_rule = url_rule
55
+        self._view_func = view_func
56
+        self._options = options or {}
57
+        self._keywords = re.findall(r'<(.+?)>', url_rule)
58
+
59
+        # change <> to {} for use with str.format()
60
+        self._url_format = self._url_rule.replace('<', '{').replace('>', '}')
61
+
62
+        # Make a regex pattern for matching incoming URLs
63
+        rule = self._url_rule
64
+        if rule != '/':
65
+            # Except for a path of '/', the trailing slash is optional.
66
+            rule = self._url_rule.rstrip('/') + '/?'
67
+        p = rule.replace('<', '(?P<').replace('>', '>[^/]+?)')
68
+
69
+        try:
70
+            self._regex = re.compile('^' + p + '$')
71
+        except re.error:
72
+            raise ValueError('There was a problem creating this URL rule. '
73
+                             'Ensure you do not have any unpaired angle '
74
+                             'brackets: "<" or ">"')
75
+
76
+    def __eq__(self, other):
77
+        if isinstance(other, UrlRule):
78
+            return (
79
+                (self._name, self._url_rule, self._view_func, self._options) ==
80
+                (other._name, other._url_rule, other._view_func, other._options)
81
+            )
82
+        else:
83
+            raise NotImplementedError
84
+
85
+    def __ne__(self, other):
86
+        return not self == other
87
+
88
+    def match(self, path):
89
+        """Attempts to match a url to the given path.
90
+
91
+        If successful, a tuple is returned. The first item is the matched
92
+        function and the second item is a dictionary containing items to be
93
+        passed to the function parsed from the provided path.
94
+
95
+        Args:
96
+            path (str): The URL path.
97
+
98
+        Raises:
99
+            NotFoundException: If the provided path does not match this
100
+                url rule.
101
+        """
102
+        m = self._regex.search(path)
103
+        if not m:
104
+            raise NotFoundException
105
+
106
+        # urlunencode the values
107
+        items = dict((key, unquote_plus(val))
108
+                     for key, val in m.groupdict().items())
109
+
110
+        # unpickle any items if present
111
+        items = unpickle_dict(items)
112
+
113
+        # We need to update our dictionary with default values provided in
114
+        # options if the keys don't already exist.
115
+        [items.setdefault(key, val) for key, val in self._options.items()]
116
+        return self._view_func, items
117
+
118
+    def _make_path(self, items):
119
+        """Returns a relative path for the given dictionary of items.
120
+
121
+        Uses this url rule's url pattern and replaces instances of <var_name>
122
+        with the appropriate value from the items dict.
123
+        """
124
+        for key, val in items.items():
125
+            if not isinstance(val, basestring):
126
+                raise TypeError('Value "%s" for key "%s" must be an instance'
127
+                                ' of basestring' % (val, key))
128
+            items[key] = quote_plus(val)
129
+
130
+        try:
131
+            path = self._url_format.format(**items)
132
+        except AttributeError:
133
+            # Old version of python
134
+            path = self._url_format
135
+            for key, val in items.items():
136
+                path = path.replace('{%s}' % key, val)
137
+        return path
138
+
139
+    def _make_qs(self, items):
140
+        """Returns a query string for the given dictionary of items. All keys
141
+        and values in the provided items will be urlencoded. If necessary, any
142
+        python objects will be pickled before being urlencoded.
143
+        """
144
+        return urlencode(pickle_dict(items))
145
+
146
+    def make_path_qs(self, items):
147
+        """Returns a relative path complete with query string for the given
148
+        dictionary of items.
149
+
150
+        Any items with keys matching this rule's url pattern will be inserted
151
+        into the path. Any remaining items will be appended as query string
152
+        parameters.
153
+
154
+        All items will be urlencoded. Any items which are not instances of
155
+        basestring, or int/long will be pickled before being urlencoded.
156
+
157
+        .. warning:: The pickling of items only works for key/value pairs which
158
+                     will be in the query string. This behavior should only be
159
+                     used for the simplest of python objects. It causes the
160
+                     URL to get very lengthy (and unreadable) and Kodi has a
161
+                     hard limit on URL length. See the caching section if you
162
+                     need to persist a large amount of data between requests.
163
+        """
164
+        # Convert any ints and longs to strings
165
+        for key, val in items.items():
166
+            if isinstance(val, (int, long)):
167
+                items[key] = str(val)
168
+
169
+        # First use our defaults passed when registering the rule
170
+        url_items = dict((key, val) for key, val in self._options.items()
171
+                         if key in self._keywords)
172
+
173
+        # Now update with any items explicitly passed to url_for
174
+        url_items.update((key, val) for key, val in items.items()
175
+                         if key in self._keywords)
176
+
177
+        # Create the path
178
+        path = self._make_path(url_items)
179
+
180
+        # Extra arguments get tacked on to the query string
181
+        qs_items = dict((key, val) for key, val in items.items()
182
+                        if key not in self._keywords)
183
+        qs = self._make_qs(qs_items)
184
+
185
+        if qs:
186
+            return '?'.join([path, qs])
187
+        return path
188
+
189
+    @property
190
+    def regex(self):
191
+        """The regex for matching paths against this url rule."""
192
+        return self._regex
193
+
194
+    @property
195
+    def view_func(self):
196
+        """The bound function"""
197
+        return self._view_func
198
+
199
+    @property
200
+    def url_format(self):
201
+        """The url pattern"""
202
+        return self._url_format
203
+
204
+    @property
205
+    def name(self):
206
+        """The name of this url rule."""
207
+        return self._name
208
+
209
+    @property
210
+    def keywords(self):
211
+        """The list of path keywords for this url rule."""
212
+        return self._keywords

+ 575
- 0
kodiswift/xbmcmixin.py View File

@@ -0,0 +1,575 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import absolute_import
3
+
4
+import os
5
+import warnings
6
+from datetime import timedelta
7
+from functools import wraps
8
+
9
+import kodiswift
10
+from kodiswift import xbmc, xbmcplugin, xbmcgui, CLI_MODE
11
+from kodiswift.constants import SortMethod
12
+from kodiswift.logger import log
13
+from kodiswift.storage import TimedStorage, UnknownFormat
14
+
15
+__all__ = ['XBMCMixin']
16
+
17
+# TODO(Sinap): Need to either break the single mixin into multiple or just use
18
+#              a parent class.
19
+
20
+
21
+# noinspection PyUnresolvedReferences,PyAttributeOutsideInit
22
+class XBMCMixin(object):
23
+    """A mixin to add Kodi helper methods. In order to use this mixin,
24
+    the child class must implement the following methods and
25
+    properties:
26
+
27
+        # Also, the child class is responsible for ensuring that this path
28
+        # exists.
29
+        self.storage_path
30
+
31
+        self.added_items
32
+
33
+        self.request
34
+
35
+        self.addon
36
+
37
+        _end_of_directory = False
38
+
39
+        _update_listing
40
+
41
+        self.handle
42
+
43
+    # optional
44
+    self.info_type: should be in ['video', 'music', 'pictures']
45
+    _memoized_storage = None
46
+    _unsynced_storage = None
47
+    # TODO: Ensure above is implemented
48
+    """
49
+
50
+    _function_cache_name = '.functions'
51
+
52
+    def cached(self, ttl=60 * 24):
53
+        """A decorator that will cache the output of the wrapped function.
54
+
55
+        The key used for the cache is the function name as well as the
56
+        `*args` and `**kwargs` passed to the function.
57
+
58
+        Args:
59
+            ttl: Time to live in minutes.
60
+
61
+        Notes:
62
+            ttl: For route caching, you should use
63
+                :meth:`kodiswift.Plugin.cached_route`.
64
+        """
65
+        def decorating_function(function):
66
+            storage = self.get_storage(
67
+                self._function_cache_name, file_format='pickle', ttl=ttl)
68
+            kwd_mark = 'f35c2d973e1bbbc61ca60fc6d7ae4eb3'
69
+
70
+            @wraps(function)
71
+            def wrapper(*args, **kwargs):
72
+                key = (function.__name__, kwd_mark) + args
73
+                if kwargs:
74
+                    key += (kwd_mark,) + tuple(sorted(kwargs.items()))
75
+
76
+                try:
77
+                    result = storage[key]
78
+                    log.debug('Storage hit for function "%s" with args "%s" '
79
+                              'and kwargs "%s"', function.__name__, args,
80
+                              kwargs)
81
+                except KeyError:
82
+                    log.debug('Storage miss for function "%s" with args "%s" '
83
+                              'and kwargs "%s"', function.__name__, args,
84
+                              kwargs)
85
+                    result = function(*args, **kwargs)
86
+                    storage[key] = result
87
+                    storage.sync()
88
+                return result
89
+
90
+            return wrapper
91
+
92
+        return decorating_function
93
+
94
+    def clear_function_cache(self):
95
+        """Clears the storage that caches results when using
96
+        :meth:`kodiswift.Plugin.cached_route` or
97
+        :meth:`kodiswift.Plugin.cached`.
98
+        """
99
+        self.get_storage(self._function_cache_name).clear()
100
+
101
+    def list_storage(self):
102
+        """Returns a list of existing stores.
103
+
104
+        The returned names can then be used to call get_storage().
105
+        """
106
+        # Filter out any storage used by kodiswift so caller doesn't corrupt
107
+        # them.
108
+        return [name for name in os.listdir(self.storage_path)
109
+                if not name.startswith('.')]
110
+
111
+    def get_storage(self, name='main', file_format='pickle', ttl=None):
112
+        """Returns a storage for the given name.
113
+
114
+        The returned storage is a fully functioning python dictionary and is
115
+        designed to be used that way. It is usually not necessary for the
116
+        caller to load or save the storage manually. If the storage does
117
+        not already exist, it will be created.
118
+
119
+        See Also:
120
+            :class:`kodiswift.TimedStorage` for more details.
121
+
122
+        Args:
123
+            name (str): The name  of the storage to retrieve.
124
+            file_format (str): Choices are 'pickle', 'csv', and 'json'.
125
+                Pickle is recommended as it supports python objects.
126
+
127
+                Notes: If a storage already exists for the given name, the
128
+                    file_format parameter is ignored. The format will be
129
+                    determined by the existing storage file.
130
+
131
+            ttl (int): The time to live for storage items specified in minutes
132
+                or None for no expiration. Since storage items aren't expired
133
+                until a storage is loaded form disk, it is possible to call
134
+                get_storage() with a different TTL than when the storage was
135
+                created. The currently specified TTL is always honored.
136
+
137
+        Returns:
138
+            kodiswift.storage.TimedStorage:
139
+        """
140
+        if not hasattr(self, '_unsynced_storage'):
141
+            self._unsynced_storage = {}
142
+        filename = os.path.join(self.storage_path, name)
143
+        try:
144
+            storage = self._unsynced_storage[filename]
145
+            log.debug('Loaded storage "%s" from memory', name)
146
+        except KeyError:
147
+            if ttl:
148
+                ttl = timedelta(minutes=ttl)
149
+            try:
150
+                storage = TimedStorage(filename, ttl, file_format=file_format)
151
+                storage.load()
152
+            except UnknownFormat:
153
+                # Thrown when the storage file is corrupted and can't be read.
154
+                # Prompt user to delete storage.
155
+                choices = ['Clear storage', 'Cancel']
156
+                ret = xbmcgui.Dialog().select(
157
+                    'A storage file is corrupted. It'
158
+                    ' is recommended to clear it.', choices)
159
+                if ret == 0:
160
+                    os.remove(filename)
161
+                    storage = TimedStorage(filename, ttl,
162
+                                           file_format=file_format)
163
+                else:
164
+                    raise Exception('Corrupted storage file at %s' % filename)
165
+
166
+            self._unsynced_storage[filename] = storage
167
+            log.debug('Loaded storage "%s" from disk', name)
168
+        return storage
169
+
170
+    def get_string(self, string_id):
171
+        """Returns the localized string from strings.po or strings.xml for the
172
+        given string_id.
173
+        """
174
+        string_id = int(string_id)
175
+        if not hasattr(self, '_strings'):
176
+            self._strings = {}
177
+        if string_id not in self._strings:
178
+            self._strings[string_id] = self.addon.getLocalizedString(string_id)
179
+        return self._strings[string_id]
180
+
181
+    def set_content(self, content):
182
+        """Sets the content type for the plugin."""
183
+        contents = ['files', 'songs', 'artists', 'albums', 'movies', 'tvshows',
184
+                    'episodes', 'musicvideos']
185
+        if content not in contents:
186
+            self.log.warning('Content type "%s" is not valid', content)
187
+        xbmcplugin.setContent(self.handle, content)
188
+
189
+    def get_setting(self, key, converter=None, choices=None):
190
+        """Returns the settings value for the provided key.
191
+
192
+        If converter is str, unicode, bool or int the settings value will be
193
+        returned converted to the provided type. If choices is an instance of
194
+        list or tuple its item at position of the settings value be returned.
195
+
196
+        Args:
197
+            key (str): The ID of the setting defined in settings.xml.
198
+            converter (Optional[str, unicode, bool, int]): How to convert the
199
+                setting value.
200
+                TODO(Sinap): Maybe this should just be a callable object?
201
+            choices (Optional[list,tuple]):
202
+
203
+        Notes:
204
+            converter: It is suggested to always use unicode for
205
+                text-settings because else xbmc returns utf-8 encoded strings.
206
+
207
+        Examples:
208
+            * ``plugin.get_setting('per_page', int)``
209
+            * ``plugin.get_setting('password', unicode)``
210
+            * ``plugin.get_setting('force_viewmode', bool)``
211
+            * ``plugin.get_setting('content', choices=('videos', 'movies'))``
212
+        """
213
+        # TODO: allow pickling of settings items?
214
+        # TODO: STUB THIS OUT ON CLI
215
+        value = self.addon.getSetting(key)
216
+        if converter is str:
217
+            return value
218
+        elif converter is unicode:
219
+            return value.decode('utf-8')
220
+        elif converter is bool:
221
+            return not(value.lower()=="false" or value=="0")
222
+        elif converter is int:
223
+            return int(value)
224
+        elif isinstance(choices, (list, tuple)):
225
+            return choices[int(value)]
226
+        elif converter is None:
227
+            log.warning('No converter provided, unicode should be used, '
228
+                        'but returning str value')
229
+            return value
230
+        else:
231
+            raise TypeError('Acceptable converters are str, unicode, bool and '
232
+                            'int. Acceptable choices are instances of list '
233
+                            ' or tuple.')
234
+
235
+    def set_setting(self, key, val):
236
+        # TODO: STUB THIS OUT ON CLI - setSetting takes id=x, value=x throws an error otherwise
237
+        return self.addon.setSetting(id=key, value=val)
238
+
239
+    def load_addon_settings(self):
240
+        """ Mock loading addon settings from user_data/settings.xml file.
241
+        If settings.xml does no exists, create using default values"""
242
+        if not CLI_MODE:
243
+            return
244
+        from xml.dom.minidom import parse
245
+        xml = parse(os.path.join("resources","language","English","strings.xml"))
246
+        self.addon._strings = dict((tag.getAttribute('id'), tag.firstChild.data) for tag in xml.getElementsByTagName('string'))
247
+        settings_file = os.path.join("addon_data","settings.xml")
248
+        if not os.path.exists(settings_file):
249
+            xml = parse(os.path.join("resources","settings.xml"))
250
+            self.addon._settings = dict((tag.getAttribute('id'), tag.attributes["default"].value)for tag in xml.getElementsByTagName('setting') if "id" in tag.attributes.keys())
251
+            if not os.path.exists("addon_data"):
252
+                os.mkdir("addon_data")
253
+            with open(settings_file,"w") as f:
254
+                f.write("<settings>\n")
255
+                for s in self.addon._settings:
256
+                    f.write('    <setting id="%s" value="%s" />\n'%(s,self.addon._settings[s]))
257
+                f.write("</settings>\n")
258
+        else:
259
+            xml = parse(settings_file)
260
+            self.addon._settings = dict((tag.getAttribute('id'), tag.attributes["value"].value) for tag in xml.getElementsByTagName('setting'))
261
+
262
+    def open_settings(self):
263
+        """Opens the settings dialog within Kodi"""
264
+        self.addon.openSettings()
265
+
266
+    @staticmethod
267
+    def add_to_playlist(items, playlist='video'):
268
+        """Adds the provided list of items to the specified playlist.
269
+        Available playlists include *video* and *music*.
270
+        """
271
+        playlists = {'music': 0, 'video': 1}
272
+        if playlist not in playlists:
273
+            raise ValueError('Playlist "%s" is invalid.' % playlist)
274
+
275
+        selected_playlist = xbmc.PlayList(playlists[playlist])
276
+        _items = []
277
+        for item in items:
278
+            if not hasattr(item, 'as_xbmc_listitem'):
279
+                if 'info_type' in item:
280
+                    log.warning('info_type key has no affect for playlist '
281
+                                'items as the info_type is inferred from the '
282
+                                'playlist type.')
283
+                # info_type has to be same as the playlist type
284
+                item['info_type'] = playlist
285
+                item = kodiswift.ListItem.from_dict(**item)
286
+            _items.append(item)
287
+            selected_playlist.add(item.get_path(), item.as_xbmc_listitem())
288
+        return _items
289
+
290
+    @staticmethod
291
+    def get_view_mode_id(view_mode):
292
+        warnings.warn('get_view_mode_id is deprecated.', DeprecationWarning)
293
+        return None
294
+
295
+    @staticmethod
296
+    def set_view_mode(view_mode_id):
297
+        """Calls Kodi's Container.SetViewMode. Requires an integer
298
+        view_mode_id"""
299
+        xbmc.executebuiltin('Container.SetViewMode(%d)' % view_mode_id)
300
+
301
+    def keyboard(self, default=None, heading=None, hidden=False):
302
+        """Displays the keyboard input window to the user. If the user does not
303
+        cancel the modal, the value entered by the user will be returned.
304
+
305
+        :param default: The placeholder text used to prepopulate the input
306
+                        field.
307
+        :param heading: The heading for the window. Defaults to the current
308
+                        addon's name. If you require a blank heading, pass an
309
+                        empty string.
310
+        :param hidden: Whether or not the input field should be masked with
311
+                       stars, e.g. a password field.
312
+        """
313
+        if heading is None:
314
+            heading = self.addon.getAddonInfo('name')
315
+        if default is None:
316
+            default = ''
317
+        keyboard = xbmc.Keyboard(default, heading, hidden)
318
+        keyboard.doModal()
319
+        if keyboard.isConfirmed():
320
+            return keyboard.getText()
321
+
322
+    def notify(self, msg='', title=None, delay=5000, image=''):
323
+        """Displays a temporary notification message to the user. If
324
+        title is not provided, the plugin name will be used. To have a
325
+        blank title, pass '' for the title argument. The delay argument
326
+        is in milliseconds.
327
+        """
328
+        if not image:
329
+            image = xbmcgui.NOTIFICATION_INFO
330
+        if not msg:
331
+            log.warning('Empty message for notification dialog')
332
+        if title is None:
333
+            title = self.addon.getAddonInfo('name')
334
+        if isinstance(msg,unicode):
335
+            msg = msg.encode("utf8")
336
+        if isinstance(title,unicode):
337
+            title = title.encode("utf8")
338
+        xbmcgui.Dialog().notification(title, msg, image, delay)
339
+        #xbmc.executebuiltin('Notification("%s", "%s", "%s", "%s")' % (msg, title, delay, image))
340
+
341
+    def set_resolved_url(self, item=None, subtitles=None):
342
+        """Takes a url or a listitem to be played. Used in conjunction with a
343
+        playable list item with a path that calls back into your addon.
344
+
345
+        :param item: A playable list item or url. Pass None to alert Kodi of a
346
+                     failure to resolve the item.
347
+
348
+                     .. warning:: When using set_resolved_url you should ensure
349
+                                  the initial playable item (which calls back
350
+                                  into your addon) doesn't have a trailing
351
+                                  slash in the URL. Otherwise it won't work
352
+                                  reliably with Kodi's PlayMedia().
353
+        :param subtitles: A URL to a remote subtitles file or a local filename
354
+                          for a subtitles file to be played along with the
355
+                          item.
356
+        """
357
+        if self._end_of_directory:
358
+            raise Exception('Current Kodi handle has been removed. Either '
359
+                            'set_resolved_url(), end_of_directory(), or '
360
+                            'finish() has already been called.')
361
+        self._end_of_directory = True
362
+
363
+        succeeded = True
364
+        if item is None:
365
+            # None item indicates the resolve url failed.
366
+            item = {}
367
+            succeeded = False
368
+
369
+        if isinstance(item, basestring):
370
+            # caller is passing a url instead of an item dict
371
+            item = {'path': item}
372
+
373
+        item = self._listitemify(item)
374
+        item.set_played(True)
375
+        xbmcplugin.setResolvedUrl(self.handle, succeeded,
376
+                                  item.as_xbmc_listitem())
377
+
378
+        # call to _add_subtitles must be after setResolvedUrl
379
+        if subtitles:
380
+            if isinstance(subtitles,list):
381
+                for subtitle in subtitles:
382
+                    self._add_subtitles(subtitle)
383
+            else:
384
+                self._add_subtitles(subtitles)
385
+
386
+        return [item]
387
+
388
+    def play_video(self, item, player=None):
389
+        if isinstance(item, dict):
390
+            item['info_type'] = 'video'
391
+
392
+        item = self._listitemify(item)
393
+        item.set_played(True)
394
+        if player:
395
+            _player = xbmc.Player(player)
396
+        else:
397
+            _player = xbmc.Player()
398
+        _player.play(item.path, item.as_xbmc_listitem())
399
+        return [item]
400
+
401
+    def add_items(self, items):
402
+        """Adds ListItems to the Kodi interface.
403
+
404
+        Each item in the provided list should either be instances of
405
+        kodiswift.ListItem, or regular dictionaries that will be passed
406
+        to kodiswift.ListItem.from_dict.
407
+
408
+        Args:
409
+            items: An iterable of items where each item is either a
410
+                dictionary with keys/values suitable for passing to
411
+                :meth:`kodiswift.ListItem.from_dict` or an instance of
412
+                :class:`kodiswift.ListItem`.
413
+
414
+        Returns:
415
+            kodiswift.ListItem: The list of ListItems.
416
+        """
417
+        _items = [self._listitemify(item) for item in items]
418
+        if hasattr(item, 'as_tuple'):
419
+            tuples = [item.as_tupe() for item in _items]
420
+        else:
421
+            tuples = [(item.getProperty("url"), item, True if item.getProperty("is_folder") else False) for item in _items]
422
+        xbmcplugin.addDirectoryItems(self.handle, tuples, len(tuples))
423
+
424
+        # We need to keep track internally of added items so we can return them
425
+        # all at the end for testing purposes
426
+        self.added_items.extend(_items)
427
+
428
+        # Possibly need an if statement if only for debug mode
429
+        return _items
430
+
431
+    def add_sort_method(self, sort_method, label2_mask=None):
432
+        """A wrapper for `xbmcplugin.addSortMethod()
433
+        <http://mirrors.xbmc.org/docs/python-docs/xbmcplugin.html#-addSortMethod>`_.
434
+        You can use ``dir(kodiswift.SortMethod)`` to list all available sort
435
+        methods.
436
+
437
+        Args:
438
+            sort_method: A valid sort method. You can provided the constant
439
+                from xbmcplugin, an attribute of SortMethod, or a string name.
440
+                For instance, the following method calls are all equivalent:
441
+                 * ``plugin.add_sort_method(xbmcplugin.SORT_METHOD_TITLE)``
442
+                 * ``plugin.add_sort_method(SortMethod.TITLE)``
443
+                 * ``plugin.add_sort_method('title')``
444
+            label2_mask: A mask pattern for label2. See the `Kodi
445
+                documentation <http://mirrors.xbmc.org/docs/python-docs/xbmcplugin.html#-addSortMethod>`_
446
+                for more information.
447
+        """
448
+        try:
449
+            # Assume it's a string and we need to get the actual int value
450
+            sort_method = SortMethod.from_string(sort_method)
451
+        except AttributeError:
452
+            # sort_method was already an int (or a bad value)
453
+            pass
454
+
455
+        if label2_mask:
456
+            xbmcplugin.addSortMethod(self.handle, sort_method, label2_mask)
457
+        else:
458
+            xbmcplugin.addSortMethod(self.handle, sort_method)
459
+
460
+    def end_of_directory(self, succeeded=True, update_listing=False,
461
+                         cache_to_disc=True):
462
+        """Wrapper for xbmcplugin.endOfDirectory. Records state in
463
+        self._end_of_directory.
464
+
465
+        Typically it is not necessary to call this method directly, as
466
+        calling :meth:`~kodiswift.Plugin.finish` will call this method.
467
+        """
468
+        self._update_listing = update_listing
469
+        if not self._end_of_directory:
470
+            self._end_of_directory = True
471
+            # Finalize the directory items
472
+            return xbmcplugin.endOfDirectory(self.handle, succeeded,
473
+                                             update_listing, cache_to_disc)
474
+        else:
475
+            raise Exception('Already called endOfDirectory.')
476
+
477
+    def finish(self, items=None, sort_methods=None, succeeded=True,
478
+               update_listing=False, cache_to_disc=True, view_mode=None):
479
+        """Adds the provided items to the Kodi interface.
480
+
481
+        Args:
482
+            items (List[Dict[str, str]]]): an iterable of items where each
483
+                item is either a dictionary with keys/values suitable for
484
+                passing to :meth:`kodiswift.ListItem.from_dict` or an
485
+                instance of :class:`kodiswift.ListItem`.
486
+
487
+            sort_methods (Union[List[str], str]): A list of valid Kodi
488
+                sort_methods. Each item in the list can either be a sort
489
+                method or a tuple of `sort_method, label2_mask`.
490
+                See :meth:`add_sort_method` for more detail concerning
491
+                valid sort_methods.
492
+
493
+            succeeded (bool):
494
+            update_listing (bool):
495
+            cache_to_disc (bool): Whether to tell Kodi to cache this folder
496
+                to disk.
497
+            view_mode (Union[str, int]): Can either be an integer
498
+                (or parsable integer string) corresponding to a view_mode or
499
+                the name of a type of view. Currently the only view type
500
+                supported is 'thumbnail'.
501
+
502
+        Returns:
503
+            List[kodiswift.listitem.ListItem]: A list of all ListItems added
504
+                to the Kodi interface.
505
+        """
506
+        # If we have any items, add them. Items are optional here.
507
+        if items:
508
+            self.add_items(items)
509
+        if sort_methods:
510
+            for sort_method in sort_methods:
511
+                if isinstance(sort_method, (list, tuple)):
512
+                    self.add_sort_method(*sort_method)
513
+                else:
514
+                    self.add_sort_method(sort_method)
515
+
516
+        # Attempt to set a view_mode if given
517
+        if view_mode is not None:
518
+            # First check if we were given an integer or parsable integer
519
+            try:
520
+                view_mode_id = int(view_mode)
521
+            except ValueError:
522
+                view_mode_id = None
523
+            if view_mode_id is not None:
524
+                self.set_view_mode(view_mode_id)
525
+
526
+        # Finalize the directory items
527
+        self.end_of_directory(succeeded, update_listing, cache_to_disc)
528
+
529
+        # Return the cached list of all the list items that were added
530
+        return self.added_items
531
+
532
+    def _listitemify(self, item):
533
+        """Creates an kodiswift.ListItem if the provided value for item is a
534
+        dict. If item is already a valid kodiswift.ListItem, the item is
535
+        returned unmodified.
536
+        """
537
+        info_type = self.info_type if hasattr(self, 'info_type') else 'video'
538
+
539
+        # Create ListItems for anything that is not already an instance of
540
+        # ListItem
541
+        if not hasattr(item, 'as_tuple') and hasattr(item, 'keys'):
542
+            if 'info_type' not in item:
543
+                item['info_type'] = info_type
544
+            item = kodiswift.ListItem.from_dict(**item)
545
+        return item
546
+
547
+    @staticmethod
548
+    def _add_subtitles(subtitles):
549
+        """Adds subtitles to playing video.
550
+
551
+        Warnings:
552
+            You must start playing a video before calling this method or it
553
+            will raise and Exception after 30 seconds.
554
+
555
+        Args:
556
+            subtitles (str): A URL to a remote subtitles file or a local
557
+                filename for a subtitles file.
558
+        """
559
+        # This method is named with an underscore to suggest that callers pass
560
+        # the subtitles argument to set_resolved_url instead of calling this
561
+        # method directly. This is to ensure a video is played before calling
562
+        # this method.
563
+        player = xbmc.Player()
564
+        monitor = xbmc.Monitor()
565
+        while not monitor.abortRequested():
566
+            if monitor.waitForAbort(30):
567
+                # Abort requested, so exit.
568
+                break
569
+            elif player.isPlaying():
570
+                # No abort requested after 30 seconds and a video is playing
571
+                # so add the subtitles and exit.
572
+                player.setSubtitles(subtitles)
573
+                break
574
+            else:
575
+                raise Exception('No video playing. Aborted after 30 seconds.')

+ 1946
- 0
project.wpr
File diff suppressed because it is too large
View File


+ 34
- 0
readme.md View File

@@ -0,0 +1,34 @@
1
+PlayStream
2
+==========
3
+
4
+Kodi plugin to to play various online streams (mostly Latvian).
5
+Stream sources are in  subfolder "resources/lib/sources", and can be added/customized
6
+Currently implemented:
7
+- Main menu and manual stream definition in sources/streams.cfg file
8
+- **replay.lsm.lv** media portal content (Latvian TV)
9
+- **skaties.lv** media portal content (MTG media portal Latvian version), other countries MTG contents are available through customizing main menu (country=xx)
10
+- **lattelecom.tv(shortcut.lv)** media portal content
11
+- **play24.lv** media portal content
12
+- **viaplay.lv**
13
+- **cinemalive.tv**
14
+- **movieplace.lv**
15
+- **dom.tv**
16
+- **BBC iPlayer**
17
+- **Euronews**
18
+- **filmon.tv** media portal content
19
+- **ustvnow.tv**
20
+- **serialguru.ru**
21
+- **filmix.net**
22
+
23
+---
24
+Copyright (c) 2016 ivars777 (ivars777@gmail.com)
25
+Distributed under the GNU GPL v3. For full terms see http://www.gnu.org/licenses/gpl-3.0.en.html
26
+Used fragments of code from
27
+- enigma2-plugin-tv3play by Taapat (https://github.com/Taapat/enigma2-plugin-tv3play)
28
+- Kodi plugin script.module.stream.resolver (https://github.com/kodi-czsk/script.module.stream.resolver)
29
+- enigma2-plugin-youtube by Taapat (https://github.com/Taapat/enigma2-plugin-youtube), originally from https://github.com/rg3/youtube-dl/blob/master/youtube_dl
30
+
31
+
32
+
33
+
34
+

+ 0
- 0
resources/__init__.py View File


BIN
resources/icon.png View File


+ 29
- 0
resources/language/English/strings.xml View File

@@ -0,0 +1,29 @@
1
+<?xml version="1.0" encoding="utf-8" standalone="yes"?>
2
+<strings>
3
+  <!-- Plugin name -->
4
+  <string id="30000">PhotoStation</string>
5
+
6
+  <string id="40001">Viewing</string>
7
+  <string id="40002">Sever</string>
8
+  <string id="40003">Oher</string>
9
+
10
+  <string id="50001">View mode</string>
11
+  <string id="50002">Page size</string>
12
+  <string id="50003">Sorting</string>
13
+  <string id="50004">Order</string>
14
+  <string id="50005">Video quality</string>
15
+  <string id="50006">Picture quality</string>
16
+  <string id="50007">Slideshow interval</string>
17
+  <string id="50008">Slideshow repet</string>
18
+  <string id="50009">Slideshow random</string>
19
+  <string id="50010">Start page</string>
20
+
21
+  <string id="50101">PhotoStation server url</string>
22
+  <string id="50102">Username</string>
23
+  <string id="50103">Password</string>
24
+
25
+  <string id="50201">Autostart plugin</string>
26
+  <string id="50202">Save plugin statuss between calls</string>
27
+  <string id="50203">Plugin status expiring time (min)</string>
28
+  <string id="50204">Download folder</string>
29
+</strings>

+ 0
- 0
resources/lib/__init__.py View File


+ 236
- 0
resources/lib/photostation_api.py View File

@@ -0,0 +1,236 @@
1
+import os,os.path
2
+import requests, urllib, urlparse
3
+import json
4
+
5
+
6
+headers2dict = lambda  h: dict([l.strip().split(": ") for l in h.strip().splitlines()])
7
+
8
+class PhotoStationAPI():
9
+
10
+    def __init__(self, ps_url):
11
+
12
+        self.url = "%s/webapi/"%ps_url
13
+        self.sid = ""
14
+        self.user = ""
15
+        self.headers = headers2dict("""
16
+User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:51.0) Gecko/20100101 Firefox/51.0
17
+Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
18
+Accept-Language: en-US,en;q=0.5
19
+Referer: %s/photo/
20
+"""%ps_url)
21
+
22
+    def login(self,user,password):
23
+        data = "api=SYNO.PhotoStation.Auth&method=login&version=1&username=%s&password=%s&remember_me=on"%(user,password)
24
+        js = self._call2("auth.php",data)
25
+        if not js["success"]:
26
+            raise Exception("Can not login")
27
+        self.sid=js["data"]["sid"]
28
+        self.user = user
29
+        return self.sid
30
+
31
+    def logout(self):
32
+        pass
33
+
34
+    def get_album_list( self, album_id="",
35
+                        offset=0,limit=-1,
36
+                        additional="album_permission,photo_exif,video_codec,video_quality,thumb_size,file_location,item_count,album_sorting", #album_permission,photo_exif,video_codec,video_quality,thumb_size,file_location
37
+                        sort_by="preference", # 'filename', 'takendate', 'createdate', 'preference', 'default'
38
+                        sort_direction="asc"
39
+                        ):
40
+        data = "api=SYNO.PhotoStation.Album&method=list&version=1&offset=%s&limit=%s&recursive=false&type=album,photo,video&additional=%s&sort_direction=%s&sort_by=%s&ignore=thumbnail&id=%s"%(
41
+            offset,limit,additional,sort_direction,sort_by,album_id)
42
+        js = self._call2("album.php",data)
43
+        if js["success"] and "data" in js:
44
+            return js["data"]
45
+        else:
46
+            raise Exception("Error reading album list")
47
+
48
+
49
+    def get_album_list2( self, album_id="",
50
+                    offset=0,limit=-1,
51
+                    additional="album_permission,photo_exif,video_codec,video_quality,thumb_size,file_location,item_count,album_sorting", #album_permission,photo_exif,video_codec,video_quality,thumb_size,file_location
52
+                    sort_by="preference", # filename,
53
+                    sort_direction="asc"
54
+                    ):
55
+        lst = []
56
+        js = self.get_album_list(album_id,offset,limit,additional,sort_by,sort_direction)
57
+        for f in js["items"]:
58
+            if f["type"] == u"photo":
59
+                name = f["info"]["name"]
60
+                desc = [
61
+                    f["info"]["description"],
62
+                    f["additional"]["file_location"],
63
+                    f["info"]["takendate"],
64
+                    "%sx%s"%(f["info"]["resolutionx"],f["info"]["resolutiony"]),
65
+                    "%s %s"%(f["additional"]["photo_exif"]["camera"],f["additional"]["photo_exif"]["camera_model"]),
66
+                    "%s %s %s"%(f["additional"]["photo_exif"]["aperture"],f["additional"]["photo_exif"]["exposure"],f["additional"]["photo_exif"]["focal_length"])
67
+                ]
68
+            elif f["type"] == u"video":
69
+                name = f["info"]["name"]
70
+                desc = [
71
+                    f["info"]["description"],
72
+                    f["additional"]["file_location"],
73
+                ]
74
+            elif f["type"] == u"album":
75
+                name = f["info"]["name"]
76
+                desc = [
77
+                    f["info"]["description"],
78
+                    f["additional"]["file_location"],
79
+                    "%s photos, %s videos"%(f["additional"]["item_count"]["photo"],f["additional"]["item_count"]["video"])
80
+                ]
81
+            desc = filter(None, desc)
82
+            desc = "\n".join(desc)
83
+            img = self.get_thumb_url(f["id"],"small")
84
+
85
+            lst.append([
86
+                f["info"]["name"].encode("utf8"),
87
+                f["id"].encode("utf8"), #id
88
+                img.encode("utf8"), # thumburl
89
+                desc.encode("utf8"), # desc
90
+                #f["type"].encode("utf8")
91
+            ])
92
+        return lst
93
+
94
+    def get_album_info(self,album_id=""):
95
+        data = "id=%s&additional=album_sorting,item_count&api=SYNO.PhotoStation.Album&method=getinfo&version=1&ps_username=%s"%(
96
+            album_id,self.user)
97
+        js = self._call2("album.php",data)
98
+        if js["success"] and "data" in js:
99
+            return js["data"]
100
+        else:
101
+            raise Exception("Error reading album info")
102
+
103
+    def get_category(self, category_id=""):
104
+        if category_id:
105
+        # api=SYNO.PhotoStation.Category&method=list&version=1&offset=0&limit=1000
106
+            data = "id=%s&api=SYNO.PhotoStation.Category&method=listitem&version=1&offset=0&limit=500&additional=album_permission,thumb_size" % category_id
107
+        else:
108
+            data = "api=SYNO.PhotoStation.Category&method=list&version=1&offset=0&limit=1000"
109
+        js = self._call2("category.php",data)
110
+        if js["success"] and "data" in js:
111
+            return js["data"]
112
+        else:
113
+            raise Exception("Error reading album info")
114
+
115
+    def get_smart_album(self, id=""):
116
+        if id:
117
+            #TODO
118
+            data = "id=%s&api=SYNO.PhotoStation.Category&method=listitem&version=1&offset=0&limit=500&additional=album_permission,thumb_size" % id
119
+        else:
120
+            #sort_by=title&sort_direction=desc&api=SYNO.PhotoStation.SmartAlbum&method=list&version=1&offset=0&limit=500&additional=thumb_size
121
+            data = "sort_by=title&sort_direction=desc&api=SYNO.PhotoStation.SmartAlbum&method=list&version=1&offset=0&limit=500&additional=thumb_size"
122
+        js = self._call2("smart_album.php",data)
123
+        if js["success"] and "data" in js:
124
+            return js["data"]
125
+        else:
126
+            raise Exception("Error reading album info")
127
+
128
+    def get_photo_info(self,id):
129
+        data = "id=%s&version=1&api=SYNO.PhotoStation.Photo&method=getinfo&ps_username=%s&additional=album_permission,photo_exif,video_codec,video_quality,thumb_size,file_location,item_count,album_sorting"%(id,self.user)
130
+        js = self._call2("photo.php",data)
131
+        if js["success"] and "data" in js:
132
+            return js["data"][0]
133
+        else:
134
+            raise Exception("Error reading photo/video info")
135
+
136
+    def get_thumb(self,id,size="peview",rotate=0 ):
137
+        # siz = preview|small|large
138
+        data = "api=SYNO.PhotoStation.Thumb&method=get&version=1&size=%s&id=%s&rotate_version=%s"%(size,id,rotate)
139
+        content = self._call2("thumb.php",data)
140
+        return content
141
+
142
+    def get_thumb_url(self,id,size="small",rotate=0 ):
143
+        # http://home.blue.lv/photo/webapi/thumb.php?api=SYNO.PhotoStation.Thumb&method=get&version=1&size=large&id=photo_323031372f323031372d30312d787820537475626169_53353032303031372e4a5047&rotate_version=0&thumb_sig=&PHPSESSID=p51g2ssj3b2o3legsoqfqdc8r3
144
+        url = "%sthumb.php?api=SYNO.PhotoStation.Thumb&method=get&version=1&size=%s&id=%s&rotate_version=%s&thumb_sig=&PHPSESSID=%s"%(
145
+                self.url, size, id, rotate, self.sid )
146
+        return url
147
+
148
+    def get_video_streams(self,id):
149
+        urls = []
150
+        js = self.get_photo_info(id)
151
+        if not js: return urls
152
+        name = js["info"]["name"]
153
+        for q in js["additional"]["video_quality"]:
154
+            s = {}
155
+            quality_id = q["id"]
156
+            data = "api=SYNO.PhotoStation.Download&method=getvideo&version=1&id=%s&quality_id=%s"%(id,quality_id)
157
+            url = "%s/download.php/%s?%s&PHPSESSID=%s"%(self.url,name,data,self.sid)
158
+            s["name"] = name
159
+            s["url"] = url
160
+            s["img"] = self.get_thumb_url(id, size='small')
161
+            s["quality"] = "%sx%s"%(q["resolutionx"],q["resolutiony"])
162
+            s["bitrate"] = q["video_bitrate"]
163
+            s["desc"] = "%s %s\n%s\n%sx%s %s/%s"%(js["info"]["name"],js["info"]["description"],
164
+                                      js["info"]["takendate"],
165
+                                      js["additional"]["video_codec"]["resolutionx"],js["additional"]["video_codec"]["resolutiony"],js["additional"]["video_codec"]["vcodec"],js["additional"]["video_codec"]["acodec"])
166
+            urls.append(s)
167
+        return urls
168
+
169
+    def _call(self,path,data):
170
+        url = self.url+path
171
+        if isinstance(data,basestring):
172
+            data = urlparse.parse_qs(data)
173
+        if self.sid:
174
+            self.headers["Cookie"] = "PHPSESSID=%s;"%self.sid
175
+        try:
176
+            r = requests.post(url,data,headers=self.headers)
177
+            js = json.loads(r.content)
178
+            return js
179
+        except Exception as e:
180
+            return {}
181
+
182
+    def _call2(self,path,data):
183
+        "GET request to PhotoStation API"
184
+        if isinstance(data,dict):
185
+            data = urllib.urlencode(data)
186
+        if self.sid:
187
+            if not "PHPSESSID" in data:
188
+                data = data +"&PHPSESSID=%s"%self.sid
189
+            #self.headers["Cookie"] = "PHPSESSID=%s;"%self.sid
190
+        try:
191
+            url = self.url + path +"?"+data
192
+            print url
193
+            r = requests.get(url,headers=self.headers)
194
+            js = json.loads(r.content)
195
+            return js
196
+        except Exception as e:
197
+            return {}
198
+
199
+    def _check_url(self,url):
200
+        if self.sid:
201
+            if not "PHPSESSID" in url:
202
+                self.headers["Cookie"] = "PHPSESSID=%s; photo_remember_me=1"%self.sid
203
+            pass
204
+        r = requests.get(url,headers=self.headers)
205
+        if r.status_code <> 200 or "text/plain" in r.headers["Content-Type"] and "error" in r.content:
206
+            return False
207
+        else:
208
+            return True
209
+
210
+
211
+if __name__ == "__main__":
212
+    ps = PhotoStationAPI("http://home.blue.lv/photo")
213
+    print ps.login("user","Kaskade7")
214
+
215
+    js = ps.get_category("category_1")
216
+    album_id = u'album_323031372f323031372d30312d313320536c69646f73616e61'
217
+    js = ps.get_album_info(album_id)
218
+    js = ps.get_album_list2(album_id)
219
+
220
+    photo_id = "photo_323031372f323031372d30312d787820537475626169_53353032303031372e4a5047"
221
+    js = ps.get_photo_info(photo_id)
222
+    url = ps.get_thumb_url(photo_id,"large")
223
+    print url
224
+    print ps._check_url(url)
225
+
226
+    video_id = "video_323031372f323031372d30312d7878205374756261692f636c697073_30303136342e4d5453"
227
+    js = ps.get_photo_info(video_id)
228
+    urls = ps.get_video_streams(video_id)
229
+    print urls[0]["url"]
230
+    a=1
231
+
232
+
233
+
234
+
235
+
236
+

+ 32
- 0
resources/settings.xml View File

@@ -0,0 +1,32 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<settings>
3
+
4
+    <category label="40001">
5
+        <setting label="50001" id="view_mode" type="select" default="None" values="None|List|Poster|IconWall|Shift|InfoWall|WideList|Wall|Banner|FanArt" />
6
+        <setting label="50002" id="page_size" type="select" default="100" values="All|50|100|200|300|400|500|1000|2000" />
7
+        <setting label="50010" id="start_page" type="select" default="Albums" values="Albums|Categories|category_1|category_2|category_3" />
8
+        <setting label="50003" id="sorting" type="select" default="preference" values="preference|filename|takendate|createdate" />
9
+        <setting label="50004" id="order" type="select" default="asc" values="asc|desc" />
10
+        <setting label="50005" id="video_quality" type="select" default="low" values="low|high" />
11
+        <setting label="50006" id="picture_quality" type="select" default="original" values="low|high|original" />
12
+        <setting label="50007" id="interval" type="number" default="3" />
13
+        <setting label="50008" id="repeat" type="bool" default="false" />
14
+        <setting label="50009" id="random" type="bool" default="false" />
15
+        <setting type="sep" />
16
+    </category>
17
+
18
+    <category label="40002">
19
+        <setting label="50101" id="server" type="text" default="server url" />
20
+        <setting label="50102" id="user" type="text" default="change user" />
21
+        <setting label="50103" id="password" type="text" default="change password" />
22
+        <setting type="sep" />
23
+    </category>
24
+
25
+    <category label="40003">
26
+        <setting label="50201" id="autostart" type="bool" default="false" />
27
+        <setting label="50204" id="download_folder=" type="folder" default="select folder"  />
28
+        <setting label="50202" id="use_storage" type="bool" default="false" />
29
+        <setting label="50203" id="ttl" type="number" default="60" />
30
+    </category>
31
+
32
+</settings>

+ 320
- 0
wingdbstub.py View File

@@ -0,0 +1,320 @@
1
+#########################################################################
2
+""" wingdbstub.py    -- Debug stub for debuggifying Python programs
3
+
4
+Copyright (c) 1999-2001, Archaeopteryx Software, Inc.  All rights reserved.
5
+
6
+Written by Stephan R.A. Deibel and John P. Ehresman
7
+
8
+Usage:
9
+-----
10
+
11
+This is the file that Wing DB users copy into their python project 
12
+directory if they want to be able to debug programs that are launched
13
+outside of the IDE (e.g., CGI scripts, in response to a browser page
14
+load).
15
+
16
+To use this, edit the configuration values below to match your 
17
+Wing installation and requirements of your project.
18
+
19
+Then, add the following line to your code:
20
+
21
+  import wingdbstub
22
+
23
+Debugging will start immediately after this import statements.
24
+
25
+Next make sure that your IDE is running and that it's configured to accept
26
+connections from the host the debug program will be running on.
27
+
28
+Now, invoking your python file should run the code within the debugger.
29
+Note, however, that Wing will not stop in the code unless a breakpoint
30
+is set.
31
+
32
+If the debug process is started before the IDE, or is not listening
33
+at the time this module is imported then the program will run with
34
+debugging until an attach request is seen.  Attaching only works 
35
+if the .wingdebugpw file is present; see the manual for details.
36
+
37
+On win32, you either need to edit WINGHOME in this script or
38
+pass in an environment variable called WINGHOME that points to
39
+the Wing installation directory.
40
+
41
+"""
42
+#########################################################################
43
+
44
+import sys
45
+import os
46
+import imp
47
+
48
+
49
+#------------------------------------------------------------------------
50
+# Default configuration values:  Note that the named environment 
51
+# variables, if set, will override these settings.
52
+
53
+# Set this to 1 to disable all debugging; 0 to enable debugging
54
+# (WINGDB_DISABLED environment variable)
55
+kWingDebugDisabled = 0
56
+
57
+# Host:port of the IDE within which to debug: As configured in the IDE
58
+# with the Server Port preference
59
+# (WINGDB_HOSTPORT environment variable)
60
+kWingHostPort = 'localhost:50005'
61
+
62
+# Port on which to listen for connection requests, so that the
63
+# IDE can (re)attach to the debug process after it has started.
64
+# Set this to '-1' to disable listening for connection requests.
65
+# This is only used when the debug process is not attached to
66
+# an IDE or the IDE has dropped its connection. The configured
67
+# port can optionally be added to the IDE's Common Attach Hosts
68
+# preference. Note that a random port is used instead if this 
69
+# port is already in use!
70
+# (WINGDB_ATTACHPORT environment variable)
71
+kAttachPort = '50015'
72
+
73
+# Set this to a filename to log verbose information about the debugger
74
+# internals to a file.  If the file does not exist, it will be created
75
+# as long as its enclosing directory exists and is writeable.  Use 
76
+# "<stderr>" or "<stdout>".  Note that "<stderr>" may cause problems 
77
+# on win32 if the debug process is not running in a console.
78
+# (WINGDB_LOGFILE environment variable)
79
+kLogFile = None
80
+
81
+# Set to get a tremendous amount of logging from the debugger internals
82
+# (WINGDB_LOGVERYVERBOSE)
83
+kLogVeryVerbose = 0
84
+
85
+# Set this to 1 when debugging embedded scripts in an environment that
86
+# creates and reuses a Python instance across multiple script invocations:  
87
+# It turns off automatic detection of program quit so that the debug session
88
+# can span multiple script invocations.  When this is turned on, you may
89
+# need to call ProgramQuit() on the debugger object to shut down the
90
+# debugger cleanly when your application exits or discards the Python
91
+# instance.  If multiple Python instances are created in the same run,
92
+# only the first one will be able to debug unless it terminates debug
93
+# and the environment variable WINGDB_ACTIVE is unset before importing
94
+# this module in the second or later Python instance.  See the Wing
95
+# IDE manual for details.
96
+kEmbedded = 0
97
+
98
+# Path to search for the debug password file and the name of the file
99
+# to use.  The password file contains the encryption type and connect 
100
+# password for all connections to the IDE and must match the wingdebugpw
101
+# file in the profile dir used by the IDE.  Any entry of '$<winguserprofile>' 
102
+# is replaced by the wing user profile directory for the user that the 
103
+# current process is running as
104
+# (WINGDB_PWFILEPATH environment variable)
105
+kPWFilePath = [os.path.dirname(__file__), '$<winguserprofile>']
106
+kPWFileName = 'wingdebugpw'
107
+
108
+# Whether to exit if the debugger fails to run or to connect with an IDE
109
+# for whatever reason
110
+kExitOnFailure = 0
111
+
112
+#------------------------------------------------------------------------
113
+# Find Wing debugger installation location
114
+
115
+# Edit this to point to your Wing installation or set to None to use env WINGHOME
116
+# On OS X this must be set to name of the Wing application bundle
117
+# (for example, /Applications/WingIDE.app)
118
+WINGHOME = None
119
+
120
+if sys.hexversion >= 0x03000000:
121
+  def has_key(o, key):
122
+    return key in o
123
+else:
124
+  def has_key(o, key):
125
+    return o.has_key(key)
126
+    
127
+# Check environment:  Must have WINGHOME defined if still == None
128
+if WINGHOME == None:
129
+  if has_key(os.environ, 'WINGHOME'):
130
+    WINGHOME=os.environ['WINGHOME']
131
+  else:
132
+    sys.stdout.write("*******************************************************************\n")
133
+    sys.stdout.write("Error: Could not find Wing installation!  You must set WINGHOME or edit\n")
134
+    sys.stdout.write("wingdbstub.py where indicated to point it to the location where\n")
135
+    sys.stdout.write("Wing is installed.\n")
136
+    sys.exit(1)
137
+
138
+kPWFilePath.append(WINGHOME)
139
+
140
+# The user settings dir where per-user settings & patches are located.  Will be
141
+# set from environment if left as None
142
+kUserSettingsDir = None
143
+if kUserSettingsDir is None:
144
+  kUserSettingsDir = os.environ.get('WINGDB_USERSETTINGS')
145
+  
146
+def _FindActualWingHome(winghome):
147
+  """ Find the actual directory to use for winghome.  Needed on OS X
148
+  where the .app directory is the preferred dir to use for WINGHOME and
149
+  .app/Contents/MacOS is accepted for backward compatibility. """
150
+  
151
+  if sys.platform != 'darwin':
152
+    return winghome
153
+  
154
+  app_dir = None
155
+  if os.path.isdir(winghome):
156
+    if winghome.endswith('/'):
157
+      wo_slash = winghome[:-1]
158
+    else:
159
+      wo_slash = winghome
160
+      
161
+    if wo_slash.endswith('.app'):
162
+      app_dir = wo_slash
163
+    elif wo_slash.endswith('.app/Contents/MacOS'):
164
+      app_dir = wo_slash[:-len('/Contents/MacOS')]
165
+    
166
+  if app_dir and os.path.isdir(os.path.join(app_dir, 'Contents', 'Resources')):
167
+    return os.path.join(app_dir, 'Contents', 'Resources')
168
+  
169
+  return winghome
170
+  
171
+def _ImportWingdb(winghome, user_settings=None):
172
+  """ Find & import wingdb module. """
173
+  
174
+  try:
175
+    exec_dict = {}
176
+    execfile(os.path.join(winghome, 'bin', '_patchsupport.py'), exec_dict)
177
+    find_matching = exec_dict['FindMatching']
178
+    dir_list = find_matching('bin', winghome, user_settings)
179
+  except Exception:
180
+    dir_list = []
181
+  dir_list.extend([os.path.join(winghome, 'bin'), os.path.join(winghome, 'src')])
182
+  for path in dir_list:
183
+    try:
184
+      f, p, d = imp.find_module('wingdb', [path])
185
+      try:
186
+        return imp.load_module('wingdb', f, p, d)
187
+      finally:
188
+        if f is not None:
189
+          f.close()
190
+      break
191
+    except ImportError:
192
+      pass
193
+
194
+#------------------------------------------------------------------------
195
+# Set debugger if it hasn't been set -- this is to handle module reloading
196
+# In the reload case, the debugger variable will be set to something
197
+try:
198
+  debugger
199
+except NameError:
200
+  debugger = None
201
+  
202
+# Unset WINGDB_ACTIVE env if it was inherited from another process
203
+# XXX Would be better to be able to call getpid() on dbgtracer but can't access it yet
204
+if 'WINGDB_ACTIVE' in os.environ and os.environ['WINGDB_ACTIVE'] != str(os.getpid()):
205
+  del os.environ['WINGDB_ACTIVE']
206
+
207
+# Start debugging if not disabled and this module has never been imported
208
+# before
209
+if (not kWingDebugDisabled and debugger is None
210
+    and not has_key(os.environ, 'WINGDB_DISABLED') and 
211
+    not has_key(os.environ, 'WINGDB_ACTIVE')):
212
+
213
+  exit_on_fail = 0
214
+  
215
+  try:
216
+    # Obtain exit if fails value
217
+    exit_on_fail = os.environ.get('WINGDB_EXITONFAILURE', kExitOnFailure)
218
+    
219
+    # Obtain configuration for log file to use, if any
220
+    logfile = os.environ.get('WINGDB_LOGFILE', kLogFile)
221
+    if logfile == '-' or logfile == None or len(logfile.strip()) == 0:
222
+      logfile = None
223
+
224
+    very_verbose_log = os.environ.get('WINGDB_LOGVERYVERBOSE', kLogVeryVerbose)
225
+    if type(very_verbose_log) == type('') and very_verbose_log.strip() == '':
226
+      very_verbose_log = 0
227
+      
228
+    # Determine remote host/port where the IDE is running
229
+    hostport = os.environ.get('WINGDB_HOSTPORT', kWingHostPort)
230
+    colonpos = hostport.find(':')
231
+    host = hostport[:colonpos]
232
+    port = int(hostport[colonpos+1:])
233
+  
234
+    # Determine port to listen on locally for attach requests
235
+    attachport = int(os.environ.get('WINGDB_ATTACHPORT', kAttachPort))
236
+  
237
+    # Check if running embedded script
238
+    embedded = int(os.environ.get('WINGDB_EMBEDDED', kEmbedded))
239
+  
240
+    # Obtain debug password file search path
241
+    if has_key(os.environ, 'WINGDB_PWFILEPATH'):
242
+      pwfile_path = os.environ['WINGDB_PWFILEPATH'].split(os.pathsep)
243
+    else:
244
+      pwfile_path = kPWFilePath
245
+    
246
+    # Obtain debug password file name
247
+    if has_key(os.environ, 'WINGDB_PWFILENAME'):
248
+      pwfile_name = os.environ['WINGDB_PWFILENAME']
249
+    else:
250
+      pwfile_name = kPWFileName
251
+    
252
+    # Load wingdb.py
253
+    actual_winghome = _FindActualWingHome(WINGHOME)
254
+    wingdb = _ImportWingdb(actual_winghome, kUserSettingsDir)
255
+    if wingdb == None:
256
+      sys.stdout.write("*******************************************************************\n")
257
+      sys.stdout.write("Error: Cannot find wingdb.py in $(WINGHOME)/bin or $(WINGHOME)/src\n")
258
+      sys.stdout.write("Error: Please check the WINGHOME definition in wingdbstub.py\n")
259
+      sys.exit(2)
260
+    
261
+    # Find the netserver module and create an error stream
262
+    netserver = wingdb.FindNetServerModule(actual_winghome, kUserSettingsDir)
263
+    err = wingdb.CreateErrStream(netserver, logfile, very_verbose_log)
264
+    
265
+    # Start debugging
266
+    debugger = netserver.CNetworkServer(host, port, attachport, err, 
267
+                                        pwfile_path=pwfile_path,
268
+                                        pwfile_name=pwfile_name,
269
+                                        autoquit=not embedded)
270
+    debugger.StartDebug(stophere=0)
271
+    os.environ['WINGDB_ACTIVE'] = str(os.getpid())
272
+    if debugger.ChannelClosed():
273
+      raise ValueError('Not connected')
274
+    
275
+  except:
276
+    if exit_on_fail:
277
+      raise
278
+    else:
279
+      pass
280
+
281
+def Ensure(require_connection=1, require_debugger=1):
282
+  """ Ensure the debugger is started and attempt to connect to the IDE if
283
+  not already connected.  Will raise a ValueError if:
284
+  
285
+  * the require_connection arg is true and the debugger is unable to connect
286
+  * the require_debugger arg is true and the debugger cannot be loaded
287
+  
288
+  If SuspendDebug() has been called through the low-level API, calling
289
+  Ensure() resets the suspend count to zero and additional calls to
290
+  ResumeDebug() will be ignored until SuspendDebug() is called again.
291
+  
292
+  Note that a change to the host & port to connect to will only
293
+  be use if a new connection is made.
294
+  
295
+  """
296
+  
297
+  if debugger is None:
298
+    if require_debugger:
299
+      raise ValueError("No debugger")
300
+    return
301
+
302
+  hostport = os.environ.get('WINGDB_HOSTPORT', kWingHostPort)
303
+  colonpos = hostport.find(':')
304
+  host = hostport[:colonpos]
305
+  port = int(hostport[colonpos+1:])
306
+  
307
+  resumed = debugger.ResumeDebug()
308
+  while resumed > 0:
309
+    resumed = debugger.ResumeDebug()
310
+  
311
+  debugger.SetClientAddress((host, port))  
312
+  
313
+  if not debugger.DebugActive():
314
+    debugger.StartDebug()
315
+  elif debugger.ChannelClosed():
316
+    debugger.ConnectToClient()
317
+    
318
+  if require_connection and debugger.ChannelClosed():
319
+    raise ValueError('Not connected')
320
+