From 8bc7521ebc4b2eac08cf366b50815ee712371928 Mon Sep 17 00:00:00 2001 From: Malek Date: Tue, 19 May 2020 14:05:36 +0200 Subject: [PATCH] redesign --- README.md | 10 +- addon.py | 13 -- addon.xml | 19 +- lib/__init__.py | 51 ----- lib/client.py | 86 +++++++ lib/dlive/api.py | 190 ---------------- lib/{dlive => }/objects.py | 160 +++++++------ lib/{dispatcher.py => plugin.py} | 93 ++++---- lib/{dlive/__init__.py => queries.py} | 0 lib/service.py | 174 ++++++++++++++ lib/utils.py | 214 +++++++++++------- .../resource.language.en_gb/strings.po | 66 ------ resources/settings.xml | 27 +-- 13 files changed, 551 insertions(+), 552 deletions(-) delete mode 100644 addon.py create mode 100644 lib/client.py delete mode 100644 lib/dlive/api.py rename lib/{dlive => }/objects.py (77%) rename lib/{dispatcher.py => plugin.py} (59%) rename lib/{dlive/__init__.py => queries.py} (100%) create mode 100644 lib/service.py diff --git a/README.md b/README.md index 7c04431..d2748b1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ # plugin.video.dlive -DLive Livestreaming Addon for Kodi. +[DLive](https://dlive.tv/) Livestreaming Addon for Kodi. + +Download the latest version from [here](https://github.com/lekma/plugin.video.dlive/releases/) +([script.module.iapc](https://github.com/lekma/script.module.iapc/) is available +[here](https://github.com/lekma/script.module.iapc/releases/)). + +Alternatively you can install [this repository](https://github.com/lekma/repository.lekma/) +and install DLive from [Kodi](https://kodi.wiki/view/Add-on_manager#How_to_install_add-ons_from_a_repository). -Download the latest version from [here](https://github.com/lekma/plugin.video.dlive/releases/). diff --git a/addon.py b/addon.py deleted file mode 100644 index eb8cc75..0000000 --- a/addon.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- - - -from __future__ import absolute_import, division, unicode_literals - -import sys - -from lib.dispatcher import dispatch - - -if __name__ == "__main__": - dispatch(*sys.argv) - diff --git a/addon.xml b/addon.xml index bdc59e7..db00c0d 100644 --- a/addon.xml +++ b/addon.xml @@ -1,29 +1,34 @@ + - - + - + + video + + + true - DLive Livestreaming Addon - DLive Livestreaming Addon - en + GPL-3.0-only all https://github.com/lekma/plugin.video.dlive resources/media/icon.png resources/media/fanart.png + DLive Livestreaming Addon + DLive Livestreaming Addon + diff --git a/lib/__init__.py b/lib/__init__.py index 92cb900..0b0ecc3 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -3,54 +3,3 @@ from __future__ import absolute_import, division, unicode_literals - -_folders_schema_ = { - "livestreams": { - "": { - "id": 30004, - "action": "livestreams" - }, - "featured": { - "id": 30007, - "action": "featured" - } - }, - "categories": { - "": { - "id": 30006, - "action": "categories" - }, - "search": { - "id": 30006, - "action": "search_categories" - } - }, - "users": { - "recommended": { - "id": 30009, - "action": "recommended" - }, - "search": { - "id": 30003, - "action": "search_users" - } - }, - "search": { - "": { - "id": 30002 - } - } -} - - -_home_folders_ = ( - {"type": "livestreams", "style": "featured"}, - {"type": "users", "style": "recommended"}, - {"type": "livestreams"}, - {"type": "categories"}, - {"type": "search"} -) - - -_subfolders_defaults_ = ("users", "categories") - diff --git a/lib/client.py b/lib/client.py new file mode 100644 index 0000000..77e18b3 --- /dev/null +++ b/lib/client.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + + +from __future__ import absolute_import, division, unicode_literals + + +import objects +from utils import notify +from iapc import Client + + +# ------------------------------------------------------------------------------ +# Client +# ------------------------------------------------------------------------------ + +class DLiveClient(object): + + _live_url_ = "https://live.prd.dlive.tv/hls/live/{username}.m3u8" + + _classes_ = { + "stream": objects.User, + "user": objects.User, + "category": objects.Category, + "featured": objects.Livestreams, + "recommended": objects.Users, + "streams": objects.Livestreams, + "categories": objects.Categories, + "search_users": objects.Users, + "search_categories": objects.Categories + } + + def __init__(self): + self.client = Client() + + def query(self, key, _list_=False, **kwargs): + cls, data = self._classes_[key], getattr(self.client, key)(**kwargs) + if _list_: + return cls(data["list"], **data["pageInfo"]) + return cls(data) + + # -------------------------------------------------------------------------- + + def query_streams(self, **kwargs): + return self.query("streams", _list_=True, **kwargs) + + # -------------------------------------------------------------------------- + + def stream(self, **kwargs): + user = self.query("stream", **kwargs) + if not user.livestream: + return notify(30016, user.displayname) # Offline + return user.livestream._item(self._live_url_.format(**kwargs)) + + def user(self, **kwargs): + after = kwargs.get("after", "-1") + user = self.query("user", **kwargs) + return (user.livestream if after == "-1" else None, user.pastBroadcasts) + + def category(self, **kwargs): + streams = self.query_streams(**kwargs) + streams.category = self.query("category", **kwargs).title + return streams + + # -------------------------------------------------------------------------- + + def featured(self, **kwargs): + return self.query("featured", **kwargs) + + def recommended(self, **kwargs): + return self.query("recommended", **kwargs) + + def streams(self, **kwargs): + return self.query_streams(**kwargs) + + def categories(self, **kwargs): + return self.query("categories", _list_=True, **kwargs) + + def search_users(self, **kwargs): + return self.query("search_users", _list_=True, **kwargs) + + def search_categories(self, **kwargs): + return self.query("search_categories", _list_=True, **kwargs) + + +client = DLiveClient() + diff --git a/lib/dlive/api.py b/lib/dlive/api.py deleted file mode 100644 index d102c55..0000000 --- a/lib/dlive/api.py +++ /dev/null @@ -1,190 +0,0 @@ -# -*- coding: utf-8 -*- - - -from __future__ import absolute_import, division, unicode_literals - - -import warnings - -import requests -import m3u8 - -from . import objects, _queries_ -from ..utils import StreamQuality, notify - - -class GraphQLError(Warning): - - _unknown_ = "Unknown error ({})" - - def __init__(self, errors): - try: - error = errors[0] - except IndexError: - message = self._unknown_.format("empty 'errors' list") - else: - if isinstance(error, dict): - try: - message = error["message"] - except KeyError: - message = self._unknown_.format("missing error 'message'") - else: - message = str(error) - if not message: - message = self._unknown_.format("empty error 'message'") - super(GraphQLError, self).__init__(message) - - -class DLiveSession(requests.Session): - - def __init__(self, headers=None): - super(DLiveSession, self).__init__() - if headers: - self.headers.update(headers) - - def request(self, *args, **kwargs): - response = super(DLiveSession, self).request(*args, **kwargs) - response.raise_for_status() - return response - - -class DLiveService(object): - - _headers_ = {} - - _query_url_ = "https://graphigo.prd.dlive.tv/" - - _live_url_ = "https://live.prd.dlive.tv/hls/live/{username}.m3u8" - - def __init__(self): - self.session = DLiveSession(headers=self._headers_) - self.category_cache = objects.CategoryCache(self._get_categories_(first=64)) - - def query(self, query, **kwargs): - query, keys = _queries_[query] - json = {"query": query} - if kwargs: - json.update(variables=kwargs) - response = self.session.post(self._query_url_, json=json).json() - data = response.get("data", None) - errors = response.get("errors", None) - if errors is not None: - error = GraphQLError(errors) - if data is None: - raise error - else: - warnings.warn(error) - for key in keys: - data = data[key] - return data - - # -------------------------------------------------------------------------- - - def _query_stream_(self, **kwargs): - return self.query("stream", **kwargs) - - def _query_user_(self, **kwargs): - return self.query("user", **kwargs) - - def _query_streams_(self, **kwargs): - results = self.query("streams", **kwargs) - return (results["list"], results["pageInfo"]) - - def _query_featured_(self, **kwargs): - return (result["item"] for result in self.query("featured", **kwargs)) - - def _query_recommended_(self, **kwargs): - return (result["user"] for result in self.query("recommended", **kwargs)) - - def _query_categories_(self, **kwargs): - results = self.query("categories", **kwargs) - return (results["list"], results["pageInfo"]) - - def _search_users_(self, **kwargs): - results = self.query("search_users", **kwargs) - return (results["list"], results["pageInfo"]) - - def _search_categories_(self, **kwargs): - results = self.query("search_categories", **kwargs) - return (results["list"], results["pageInfo"]) - - # -------------------------------------------------------------------------- - - def _stream_(self, quality=0, **kwargs): - url = self._live_url_.format(**kwargs) - if quality and quality < 6: # inputstream.adaptive - manifest = m3u8.loads(self.session.get(url).text) - qualities = [StreamQuality(playlist) - for playlist in manifest.playlists - if playlist.stream_info.resolution] - qualities.sort(key=lambda x: x.height, reverse=True) # see Quality.best_match - if quality == 5: # always ask - selected = StreamQuality.select(qualities) - else: # set quality - selected = StreamQuality.best_match(quality, qualities) - if selected < 0: - return None - url = qualities[selected].uri - return url - - def _get_categories_(self, **kwargs): - items, pageInfo = self._query_categories_(**kwargs) - return objects.Categories(items, **pageInfo) - - def _categories_(self, **kwargs): - categories = self._get_categories_(**kwargs) - self.category_cache.update(categories) - return categories - - def _category_(self, categoryID): - try: - return self.category_cache[categoryID] - except KeyError: - # unfortunately I couldn't find a way to get 1 category from its id - return objects.EmptyCategory - - # -------------------------------------------------------------------------- - - def stream(self, quality=0, **kwargs): - user = objects.User(self._query_stream_(**kwargs)) - if not user.livestream: - return notify(30016, user.displayname) # Offline - url = self._stream_(quality, **kwargs) - return user.livestream._item(url) if url else None - - def user(self, **kwargs): - after = kwargs.get("after", "-1") - user = objects.User(self._query_user_(**kwargs)) - return (user.livestream if after == "-1" else None, user.pastBroadcasts) - - def category(self, **kwargs): - items, pageInfo = self._query_streams_(**kwargs) - category = self._category_(kwargs["categoryID"]) - return objects.Livestreams(items, category=category.title, **pageInfo) - - # -------------------------------------------------------------------------- - - def featured(self, **kwargs): - return objects.Livestreams(self._query_featured_(**kwargs)) - - def recommended(self, **kwargs): - return objects.Users(self._query_recommended_(**kwargs)) - - def livestreams(self, **kwargs): - items, pageInfo = self._query_streams_(**kwargs) - return objects.Livestreams(items, **pageInfo) - - def categories(self, **kwargs): - return self._categories_(**kwargs) - - def search_users(self, **kwargs): - items, pageInfo = self._search_users_(**kwargs) - return objects.Users(items, **pageInfo) - - def search_categories(self, **kwargs): - items, pageInfo = self._search_categories_(**kwargs) - return objects.Categories(items, **pageInfo) - - -service = DLiveService() - diff --git a/lib/dlive/objects.py b/lib/objects.py similarity index 77% rename from lib/dlive/objects.py rename to lib/objects.py index ffa8711..5080c39 100644 --- a/lib/dlive/objects.py +++ b/lib/objects.py @@ -8,8 +8,7 @@ from six import string_types, iteritems, with_metaclass, raise_from -from .. import _folders_schema_, _home_folders_ -from ..utils import ListItem, build_url, localized_string +from utils import ListItem, buildUrl, localizedString # ------------------------------------------------------------------------------ @@ -75,24 +74,56 @@ def __repr__(self): return _repr_.format(self) -class DLiveItems(list): - - _ctor_ = DLiveObject - _content_ = "videos" - _category_ = None - - def __init__(self, items, endCursor="-1", hasNextPage=False, content=None, category=None): - super(DLiveItems, self).__init__((self._ctor_(item) for item in items)) - self.endCursor = endCursor - self.hasNextPage = hasNextPage - self.content = content or self._content_ - self.category = category or self._category_ - - def items(self, *args): - return (item.item(*args) for item in self if item) +# folders ---------------------------------------------------------------------- +_folders_schema_ = { + "streams": { + "": { + "id": 30004 + }, + "featured": { + "id": 30007, + "action": "featured" + } + }, + "categories": { + "": { + "id": 30006 + } + }, + "users": { + "recommended": { + "id": 30009, + "action": "recommended" + } + }, + "search": { + "": { + "id": 30002 + }, + "users": { + "id": 30003, + "action": "search_users" + }, + "categories": { + "id": 30006, + "action": "search_categories" + } + } +} + + +_home_folders_ = ( + {"type": "streams", "style": "featured"}, + {"type": "users", "style": "recommended"}, + {"type": "streams"}, + {"type": "categories"}, + {"type": "search"} +) + + +_search_styles_ = ("users", "categories") -# folders ---------------------------------------------------------------------- class Folder(DLiveObject): @@ -103,15 +134,18 @@ def style(self): except KeyError: return "" - def item(self, url): + def item(self, url, **kwargs): folder = _folders_schema_[self.type][self.style] - label = localized_string(folder["id"]) + label = folder["id"] + if isinstance(label, int): + label = localizedString(label) action = folder.get("action", self.type) - plot = folder.get("plot", "") + kwargs.update(folder.get("kwargs", {})) + plot = folder.get("plot", label) if isinstance(plot, int): - plot = localized_string(plot) + plot = localizedString(plot) return ListItem( - label, build_url(url, action=action), isFolder=True, + label, buildUrl(url, action=action, **kwargs), isFolder=True, infos={"video": {"title": label, "plot": plot}}) @@ -133,9 +167,16 @@ class Language(Node): class DLiveItem(Node): + _menus_ = [] + def plot(self): return self._plot_.format(self) + def menus(self, **kwargs): + return [(localizedString(label), + action.format(addonId=getAddonId(), **kwargs)) + for label, action in self._menus_] + # categories ------------------------------------------------------------------- @@ -143,21 +184,18 @@ def plot(self): class Category(DLiveItem): _repr_ = "Category({0.backendID}, title={0.title})" - _plot_ = localized_string(30053) + _plot_ = localizedString(30053) def item(self, url, action): if self.backendID: return ListItem( self.title, - build_url(url, action=action, categoryID=self.backendID), + buildUrl(url, action=action, categoryID=self.backendID), isFolder=True, infos={"video": {"plot": self.plot()}}, poster=self.imgUrl) -EmptyCategory = Category({"backendID": -1, "title": "", "imgUrl": "", "watchingCount": 0}) - - # users ------------------------------------------------------------------------ # https://dev.dlive.tv/schema/user.doc.html @@ -165,7 +203,7 @@ class User(DLiveItem): __date__ = {"createdAt"} _repr_ = "User({0.username}, displayname={0.displayname})" - _plot_ = localized_string(30054) + _plot_ = localizedString(30054) @property def livestream(self): @@ -190,7 +228,7 @@ def followers(self): def item(self, url, action): return ListItem( self.displayname, - build_url(url, action=action, username=self.username), + buildUrl(url, action=action, username=self.username), isFolder=True, infos={"video": {"plot": self.plot()}}, poster=self.avatar) @@ -204,12 +242,12 @@ class Livestream(DLiveItem): __json__ = {"language": Language, "category": Category, "creator": User} __date__ = {"createdAt"} _repr_ = "Livestream({0.permlink})" - _plot_ = localized_string(30055) + _plot_ = localizedString(30055) _video_infos_ = {"mediatype": "video", "playcount": 0} @property def rating(self): - return localized_string(30052) if self.ageRestriction else "" + return localizedString(30052) if self.ageRestriction else "" def _item(self, path): title = " - ".join((self.creator.displayname, self.title)) @@ -222,7 +260,7 @@ def _item(self, path): def item(self, url, action): return self._item( - build_url(url, action=action, username=self.creator.username)) + buildUrl(url, action=action, username=self.creator.username)) # vods ------------------------------------------------------------------------- @@ -239,26 +277,23 @@ class PastBroadcast(DLiveItem): __json__ = {"language": Language, "category": Category, "creator": User} __date__ = {"createdAt"} _repr_ = "PastBroadcast({0.permlink})" - _plot_ = localized_string(30056) + _plot_ = localizedString(30056) _video_infos_ = {"mediatype": "video"} - #@property - #def resolutions(self): - # return [Resolution(resolution) for resolution in self["resolution"]] - @property def rating(self): - return localized_string(30052) if self.ageRestriction else "" + return localizedString(30052) if self.ageRestriction else "" def _item(self, path): streamInfos = {"video": {"duration": self.length}} - return ListItem( + item = ListItem( self.title, path, infos={"video": dict(self._video_infos_, title=self.title, plot=self.plot())}, streamInfos=streamInfos, thumb=self.thumbnailUrl) + return item def item(self, *args): return self._item(self.playbackUrl) @@ -269,6 +304,24 @@ def item(self, *args): # lists, collections # ------------------------------------------------------------------------------ +class DLiveItems(list): + + _ctor_ = DLiveObject + _content_ = "videos" + _category_ = None + + def __init__(self, items, endCursor="-1", hasNextPage=False, + content=None, category=None): + super(DLiveItems, self).__init__((self._ctor_(item) for item in items)) + self.endCursor = endCursor + self.hasNextPage = hasNextPage + self.content = content or self._content_ + self.category = category or self._category_ + + def items(self, *args): + return (item.item(*args) for item in self if item) + + class Folders(DLiveItems): _ctor_ = Folder @@ -299,30 +352,3 @@ class PastBroadcasts(DLiveItems): _ctor_ = PastBroadcast - -# ------------------------------------------------------------------------------ -# cache -# ------------------------------------------------------------------------------ - -class Cache(dict): - - def __getitem__(self, key): - return super(Cache, self).__getitem__(int(key)) - - def __setitem__(self, key, value): - return super(Cache, self).__setitem__(int(key), value) - - def __delitem__(self, key): - return super(Cache, self).__delitem__(int(key)) - - -class CategoryCache(Cache): - - def __init__(self, items=None): - if not items: - items = [] - super(CategoryCache, self).__init__({int(item.backendID): item for item in items}) - - def update(self, items): - return super(CategoryCache, self).update({int(item.backendID): item for item in items}) - diff --git a/lib/dispatcher.py b/lib/plugin.py similarity index 59% rename from lib/dispatcher.py rename to lib/plugin.py index 513ed4d..e6c1b48 100644 --- a/lib/dispatcher.py +++ b/lib/plugin.py @@ -4,14 +4,14 @@ from __future__ import absolute_import, division, unicode_literals +import sys + from six import wraps -from kodi_six import xbmc, xbmcplugin -from inputstreamhelper import Helper +from kodi_six import xbmcplugin -from .utils import parse_query, get_setting, get_subfolders, more_item -from .utils import localized_string, search_dialog -from .dlive.api import service -from .dlive.objects import Folders, Home +from client import client +from objects import Home, Folders, _search_styles_ +from utils import parseQuery, getMoreItem, searchDialog, localizedString def action(category=0): @@ -19,6 +19,7 @@ def decorator(func): func.__action__ = True @wraps(func) def wrapper(self, **kwargs): + success = False try: self.category = category self.action = func.__name__ @@ -33,27 +34,18 @@ def wrapper(self, **kwargs): return decorator +# ------------------------------------------------------------------------------ +# Dispatcher +# ------------------------------------------------------------------------------ + class Dispatcher(object): def __init__(self, url, handle): self.url = url self.handle = handle - self.limit = get_setting("items_per_page", int) - self.showNSFW = get_setting("show_nsfw", bool) - self.language = xbmc.getLanguage(xbmc.ISO_639_1) - # utils -------------------------------------------------------------------- - def play(self, item, quality=0): - if quality == 6: # inputstream.adaptive - if not Helper("hls").check_inputstream(): - return False - item.setProperty("inputstreamaddon", "inputstream.adaptive") - item.setProperty("inputstream.adaptive.manifest_type", "hls") - xbmcplugin.setResolvedUrl(self.handle, True, item) - return True - def addItem(self, item): if item and not xbmcplugin.addDirectoryItem(self.handle, *item.asItem()): raise @@ -66,7 +58,7 @@ def addItems(self, items, *args, **kwargs): raise if items.hasNextPage: kwargs["after"] = items.endCursor - self.addItem(more_item(self.url, action=self.action, **kwargs)) + self.addItem(getMoreItem(self.url, action=self.action, **kwargs)) if items.content: xbmcplugin.setContent(self.handle, items.content) if items.category: @@ -79,31 +71,32 @@ def setCategory(self, category): def endDirectory(self, success): if success and self.category: - self.setCategory(localized_string(self.category)) + self.setCategory(localizedString(self.category)) xbmcplugin.endOfDirectory(self.handle, success) + def getSubfolders(self, _type, styles): + return ({"type": _type, "style": style} for style in styles) # actions ------------------------------------------------------------------ @action() def stream(self, **kwargs): - quality = get_setting("stream_quality", int) - item = service.stream(quality, **kwargs) - return self.play(item, quality) if item else False + item = client.stream(**kwargs) + if not item: + return False + xbmcplugin.setResolvedUrl(self.handle, True, item) + return True @action() def user(self, **kwargs): - stream, vods = service.user(first=self.limit, **kwargs) + stream, vods = client.user(**kwargs) if stream: self.addItem(stream.item(self.url, "stream")) return self.addItems(vods, **kwargs) @action() def category(self, **kwargs): - return self.addItems( - service.category( - first=self.limit, showNSFW=self.showNSFW, **kwargs), - "stream", **kwargs) + return self.addItems(client.category(**kwargs), "stream", **kwargs) # -------------------------------------------------------------------------- @@ -113,52 +106,45 @@ def home(self, **kwargs): @action(30007) def featured(self, **kwargs): - return self.addItems( - service.featured(userLanguageCode=self.language, **kwargs), - "stream") + return self.addItems(client.featured(**kwargs), "stream") @action(30009) def recommended(self, **kwargs): - return self.addItems(service.recommended(**kwargs), "user") + return self.addItems(client.recommended(**kwargs), "user") @action(30004) - def livestreams(self, **kwargs): - return self.addItems( - service.livestreams( - first=self.limit, showNSFW=self.showNSFW, **kwargs), - "stream", **kwargs) + def streams(self, **kwargs): + return self.addItems(client.streams(**kwargs), "stream", **kwargs) @action(30006) def categories(self, **kwargs): - return self.addItems( - service.categories(first=self.limit, **kwargs), - "category", **kwargs) + return self.addItems(client.categories(**kwargs), "category", **kwargs) # search ------------------------------------------------------------------- @action(30002) def search(self, **kwargs): - return self.addItems(Folders(get_subfolders("search"), **kwargs)) + return self.addItems( + Folders(self.getSubfolders("search", _search_styles_))) @action(30003) def search_users(self, **kwargs): - text = kwargs.pop("text", "") or search_dialog() + text = kwargs.pop("text", "") or searchDialog() if text: return self.addItems( - service.search_users(text=text, first=self.limit, **kwargs), + client.search_users(text=text, **kwargs), "user", text=text, **kwargs) - return False # failing here is a bit stupid + return False @action(30006) def search_categories(self, **kwargs): - text = kwargs.pop("text", "") or search_dialog() + text = kwargs.pop("text", "") or searchDialog() if text: return self.addItems( - service.search_categories(text=text, first=self.limit, **kwargs), + client.search_categories(text=text, **kwargs), "category", text=text, **kwargs) - return False # failing here is a bit stupid - + return False # dispatch ----------------------------------------------------------------- @@ -169,6 +155,13 @@ def dispatch(self, **kwargs): return action(**kwargs) +# __main__ --------------------------------------------------------------------- + def dispatch(url, handle, query, *args): - Dispatcher(url, int(handle)).dispatch(**parse_query(query)) + Dispatcher(url, int(handle)).dispatch(**parseQuery(query)) + + +if __name__ == "__main__": + + dispatch(*sys.argv) diff --git a/lib/dlive/__init__.py b/lib/queries.py similarity index 100% rename from lib/dlive/__init__.py rename to lib/queries.py diff --git a/lib/service.py b/lib/service.py new file mode 100644 index 0000000..600ddd0 --- /dev/null +++ b/lib/service.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- + + +from __future__ import absolute_import, division, unicode_literals + + +import warnings + +import requests + +from kodi_six import xbmc + +from queries import _queries_ +from iapc import Service, public +from utils import DataCache, getSetting, log + + +class GraphQLError(Warning): + + _unknown_ = "Unknown error ({})" + + def __init__(self, errors): + try: + error = errors[0] + except IndexError: + message = self._unknown_.format("empty 'errors' list") + else: + if isinstance(error, dict): + try: + message = error["message"] + except KeyError: + message = self._unknown_.format("missing error 'message'") + else: + message = str(error) + if not message: + message = self._unknown_.format("empty error 'message'") + super(GraphQLError, self).__init__(message) + + +class Categories(DataCache): + + _type_ = int + _key_ = "backendID" + _missing_ = {"backendID": -1, "title": "", "imgUrl": "", "watchingCount": 0} + + def __init__(self, data): + super(Categories, self).__init__(data["list"]) + + def update(self, data): + return super(Categories, self).update(data["list"]) + + +# ------------------------------------------------------------------------------ +# Session +# ------------------------------------------------------------------------------ + +class DLiveSession(requests.Session): + + def __init__(self, headers=None): + super(DLiveSession, self).__init__() + if headers: + self.headers.update(headers) + + def request(self, *args, **kwargs): + response = super(DLiveSession, self).request(*args, **kwargs) + response.raise_for_status() + return response + + +# ------------------------------------------------------------------------------ +# Service +# ------------------------------------------------------------------------------ + +class DLiveService(Service): + + _headers_ = {} + + _query_url_ = "https://graphigo.prd.dlive.tv/" + + def __init__(self, *args, **kwargs): + super(DLiveService, self).__init__(*args, **kwargs) + self.session = DLiveSession(headers=self._headers_) + self._categories_ = Categories(self.query("categories", first=48)) + + def setup(self): + self.first = getSetting("first", int) + self.showNSFW = getSetting("showNSFW", bool) + self.userLanguageCode = xbmc.getLanguage(xbmc.ISO_639_1) + + def start(self): + log("starting service...") + self.setup() + self.serve() + log("service stopped") + + def onSettingsChanged(self): + self.setup() + + # -------------------------------------------------------------------------- + + def query(self, key, _key_=None, **kwargs): + query, keys = _queries_[key] + json = {"query": query} + if kwargs: + json.update(variables=kwargs) + response = self.session.post(self._query_url_, json=json).json() + data = response.get("data", None) + errors = response.get("errors", None) + if errors is not None: + error = GraphQLError(errors) + if data is None: + raise error + else: + warnings.warn(error) # logWarning? + for key in keys: + data = data[key] + if _key_ is not None: + return [item[_key_] for item in data] + return data + + # public api --------------------------------------------------------------- + + @public + def stream(self, **kwargs): + return self.query("stream", **kwargs) + + @public + def user(self, **kwargs): + return self.query("user", first=self.first, **kwargs) + + @public + def category(self, **kwargs): + # unfortunately I couldn't find a way to get 1 category from its id + return self._categories_[kwargs["categoryID"]] + + # -------------------------------------------------------------------------- + + @public + def featured(self, **kwargs): + return self.query("featured", _key_="item", + userLanguageCode=self.userLanguageCode, **kwargs) + + @public + def recommended(self, **kwargs): + return self.query("recommended", _key_="user", **kwargs) + + @public + def streams(self, **kwargs): + return self.query("streams", first=self.first, + showNSFW=self.showNSFW, **kwargs) + + @public + def categories(self, **kwargs): + data = self.query("categories", first=self.first, **kwargs) + self._categories_.update(data) + return data + + @public + def search_users(self, **kwargs): + return self.query("search_users", first=self.first, **kwargs) + + @public + def search_categories(self, **kwargs): + data = self.query("search_categories", first=self.first, **kwargs) + self._categories_.update(data) + return data + + +# __main__ --------------------------------------------------------------------- + +if __name__ == "__main__": + + DLiveService().start() + diff --git a/lib/utils.py b/lib/utils.py index 88f4a86..1ae346b 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -6,75 +6,140 @@ from os.path import join -from six import text_type, iteritems +from six import text_type, iteritems, text_type from six.moves.urllib.parse import parse_qsl, urlencode from kodi_six import xbmc, xbmcaddon, xbmcgui -from . import _subfolders_defaults_ +_addon_ = xbmcaddon.Addon() +_addon_id_ = _addon_.getAddonInfo("id") +_addon_name_ = _addon_.getAddonInfo("name") +_addon_path_ = xbmc.translatePath(_addon_.getAddonInfo("path")) +_addon_icon_ = xbmc.translatePath(_addon_.getAddonInfo("icon")) -addon = xbmcaddon.Addon() -addon_path = xbmc.translatePath(addon.getAddonInfo("path")) +_dialog_ = xbmcgui.Dialog() -dialog = xbmcgui.Dialog() +def getWindowId(): + return xbmcgui.getCurrentWindowId() -def parse_query(query): +def getAddonId(): + return _addon_id_ + +def getAddonName(): + return _addon_name_ + +def getAddonPath(): + return _addon_path_ + +def getAddonIcon(): + return _addon_icon_ + + +def parseQuery(query): if query.startswith("?"): query = query[1:] return dict(parse_qsl(query)) -def build_url(*args, **kwargs): +def buildUrl(*args, **kwargs): params = {k: v.encode("utf-8") if isinstance(v, text_type) else v for k, v in iteritems(kwargs)} return "?".join(("/".join(args), urlencode(params))) -def localized_string(id): +def localizedString(id): if id < 30000: return xbmc.getLocalizedString(id) - return addon.getLocalizedString(id) + return _addon_.getLocalizedString(id) + +def getMediaPath(*args): + return join(_addon_path_, "resources", "media", *args) -def get_media_path(*args): - return join(addon_path, "resources", "media", *args) +def getIcon(name): + return getMediaPath("{}.png".format(name)) -def get_icon(name): - return get_media_path("{}.png".format(name)) +# logging ---------------------------------------------------------------------- + +def log(msg, level=xbmc.LOGNOTICE): + xbmc.log("[{}] {}".format(_addon_id_, msg), level=level) -def get_subfolders(style, subfolders=_subfolders_defaults_): - return [{"type": folder, "style": style} for folder in subfolders] +def logDebug(msg): + log(msg, xbmc.LOGDEBUG) + +def logWarning(msg): + log(msg, xbmc.LOGWARNING) + +def logError(msg): + log(msg, xbmc.LOGERROR) # settings --------------------------------------------------------------------- _get_settings_ = { - bool: "getSettingBool", - int: "getSettingInt", - float: "getSettingNumber", - unicode: "getSettingString" + bool: _addon_.getSettingBool, + int: _addon_.getSettingInt, + float: _addon_.getSettingNumber, + unicode: _addon_.getSettingString } -def get_setting(id, _type=None): +def getSetting(id, _type=None): if _type is not None: - return _type(getattr(addon, _get_settings_.get(_type, "getSetting"))(id)) - return addon.getSetting(id) + return _type(_get_settings_.get(_type, _addon_.getSetting)(id)) + return _addon_.getSetting(id) -# logging ---------------------------------------------------------------------- +_set_settings_ = { + bool: _addon_.setSettingBool, + int: _addon_.setSettingInt, + float: _addon_.setSettingNumber, + unicode: _addon_.setSettingString +} -def log(msg, level=xbmc.LOGNOTICE): - xbmc.log("{}: {}".format(addon.getAddonInfo("id"), msg), level=level) +def setSetting(id, value, _type=None): + if _type is not None: + return _set_settings_.get(_type, _addon_.setSetting)(id, _type(value)) + return _addon_.setSetting(id, value) -def debug(msg): - log(msg, xbmc.LOGDEBUG) +# notify ----------------------------------------------------------------------- +iconInfo = xbmcgui.NOTIFICATION_INFO +iconWarning = xbmcgui.NOTIFICATION_WARNING +iconError = xbmcgui.NOTIFICATION_ERROR -def warn(msg): - log(msg, xbmc.LOGWARNING) +def notify(message, heading=_addon_name_, icon=_addon_icon_, time=5000): + if isinstance(message, int): + message = localizedString(message) + if isinstance(heading, int): + heading = localizedString(heading) + _dialog_.notification(heading, message, icon, time) + + +# select ----------------------------------------------------------------------- + +def selectDialog(_list, heading=_addon_name_, **kwargs): + if isinstance(heading, int): + heading = localizedString(heading) + return _dialog_.select(heading, _list, **kwargs) + + +# input ----------------------------------------------------------------------- + +def inputDialog(heading=_addon_name_, **kwargs): + if isinstance(heading, int): + heading = localizedString(heading) + return _dialog_.input(heading, **kwargs) + + +# search ----------------------------------------------------------------------- + +_search_heading_ = localizedString(30002) + +def searchDialog(): + return inputDialog(_search_heading_) # listitem --------------------------------------------------------------------- @@ -87,7 +152,7 @@ def __new__(cls, label, path, **kwargs): offscreen=True) def __init__(self, label, path, isFolder=False, infos=None, - streamInfos=None, **art): + streamInfos=None, contextMenus=None, **art): self.setIsFolder(isFolder) self.setIsPlayable(not isFolder) self.isFolder = isFolder @@ -97,6 +162,8 @@ def __init__(self, label, path, isFolder=False, infos=None, if streamInfos: for info in iteritems(streamInfos): self.addStreamInfo(*info) + if contextMenus: + self.addContextMenuItems(contextMenus) if art: self.setArt(art) @@ -107,76 +174,59 @@ def asItem(self): return self.getPath(), self, self.isFolder -_more_label_ = localized_string(30099) -_more_icon_ = get_icon("more") +_more_label_ = localizedString(30099) +_more_icon_ = getIcon("more") -def more_item(url, **kwargs): +def getMoreItem(url, **kwargs): return ListItem( - _more_label_, build_url(url, **kwargs), isFolder=True, + _more_label_, buildUrl(url, **kwargs), isFolder=True, infos={"video": {"plot": _more_label_}}, icon=_more_icon_) -# quality ---------------------------------------------------------------------- - -class Quality(object): - - # This has to reflect the 'stream_quality'/'vod_quality' settings. - # At the time of this writing, the order was: - # ["Auto", "1080p", "720p", "480p", "360p", "Always Ask", "Adaptive"] - _settings_ = (0, 1080, 720, 480, 360) +# cache ------------------------------------------------------------------------ - def __init__(self, playlist): - self.uri = playlist.uri - self.bandwidth = playlist.stream_info.bandwidth - self.width, self.height = playlist.stream_info.resolution +class Cache(dict): - def __str__(self): - return "{0.width}x{0.height}@{0.bandwidth}bps".format(self) + _type_ = text_type + _key_ = None + _missing_ = None - @classmethod - def select(cls, qualities): - return dialog.select( - cls._heading_, [str(quality) for quality in qualities]) - - @classmethod - def best_match(cls, quality, qualities): - height = cls._settings_[quality] - # we know/assume/hope the list is already sorted by height in descending order - for i, q in enumerate(qualities): - if q.height <= height: # exact match or closest below - return i - # what to do when the above fails to find a match? - # we could pop a dialog asking for manual selection, something like: - # return cls.select(qualities) - # for the time being, we just fail. - return -1 + def __init__(self, items=None): + items = items or [] + super(Cache, self).__init__(((self.key(item), item) for item in items)) + def __getitem__(self, key): + return super(Cache, self).__getitem__(self._type_(key)) -class StreamQuality(Quality): + def __setitem__(self, key, value): + return super(Cache, self).__setitem__(self._type_(key), value) - _heading_ = localized_string(30121) + def __delitem__(self, key): + return super(Cache, self).__delitem__(self._type_(key)) + def update(self, items): + return super(Cache, self).update(((self.key(item), item) for item in items)) -class VodQuality(Quality): + def __missing__(self, key): + if self._missing_ is not None: + return self._missing_ + raise KeyError(key) - _heading_ = localized_string(30122) - - -# search ----------------------------------------------------------------------- + @classmethod + def key(cls, item): + raise NotImplementedError -_search_label_ = localized_string(30002) -def search_dialog(): - return dialog.input(_search_label_) +class DataCache(Cache): + @classmethod + def key(cls, item): + return cls._type_(item[cls._key_]) -# notify ----------------------------------------------------------------------- -_notify_heading_ = localized_string(30000) +class ObjectCache(Cache): -def notify(message, heading=_notify_heading_, icon=xbmcgui.NOTIFICATION_ERROR, - time=2000): - if isinstance(message, int): - message = localized_string(message) - dialog.notification(heading, message, icon, time) + @classmethod + def key(cls, item): + return cls._type_(getattr(item, cls._key_)) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 35d1e62..8c0eb09 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -100,69 +100,3 @@ msgctxt "#30103" msgid "Show NSFW" msgstr "" -msgctxt "#30111" -msgid "Quality" -msgstr "" - -msgctxt "#30112" -msgid "Stream Quality" -msgstr "" - -msgctxt "#30113" -msgid "Vod Quality" -msgstr "" - -# qualities - -msgctxt "#30901" -msgid "Auto" -msgstr "" - -msgctxt "#30902" -msgid "Always Ask" -msgstr "" - -msgctxt "#30903" -msgid "Adaptive" -msgstr "" - -msgctxt "#30910" -msgid "144p" -msgstr "" - -msgctxt "#30911" -msgid "160p" -msgstr "" - -msgctxt "#30912" -msgid "240p" -msgstr "" - -msgctxt "#30913" -msgid "320p" -msgstr "" - -msgctxt "#30914" -msgid "360p" -msgstr "" - -msgctxt "#30915" -msgid "480p" -msgstr "" - -msgctxt "#30916" -msgid "720p" -msgstr "" - -msgctxt "#30917" -msgid "1080p" -msgstr "" - -msgctxt "#30918" -msgid "1440p" -msgstr "" - -msgctxt "#30919" -msgid "2160p" -msgstr "" - diff --git a/resources/settings.xml b/resources/settings.xml index 5eaa79a..62d1eb8 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -4,34 +4,13 @@ - - - - - - - - - - - - - - -