Kodi plugin to to play various online streams (mostly Latvian)

xbmcmixin.py 23KB

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