diff --git a/addon.py b/addon.py index 19de610..2472519 100755 --- a/addon.py +++ b/addon.py @@ -1,11 +1,13 @@ import os +import shutil +import stat import subprocess import threading -import stat from xbmcswift2 import Plugin, xbmcgui, xbmc, xbmcaddon from resources.lib.confighelper import ConfigHelper +from resources.lib.scraper import ScraperCollection STRINGS = { 'name': 30000, @@ -32,13 +34,15 @@ def index(): items = [{ 'label': 'Games', + 'thumbnail': addon_internal_path + '/resources/icons/controller.png', 'path': plugin.url_for( - endpoint='show_games' + endpoint='show_games' ) }, { 'label': 'Settings', + 'thumbnail': addon_internal_path + '/resources/icons/cog.png', 'path': plugin.url_for( - endpoint='open_settings' + endpoint='open_settings' ) }] return plugin.finish(items) @@ -62,8 +66,8 @@ def create_mapping(): progress_dialog = xbmcgui.DialogProgress() progress_dialog.create( - _('name'), - _('starting_mapping') + _('name'), + _('starting_mapping') ) log('Trying to call subprocess') @@ -95,9 +99,9 @@ def create_mapping(): if os.path.isfile(map_file) and success == 'true': confirmed = xbmcgui.Dialog().yesno( - _('name'), - _('mapping_success'), - _('set_mapping_active') + _('name'), + _('mapping_success'), + _('set_mapping_active') ) log('Dialog Yes No Value: %s' % confirmed) if confirmed: @@ -109,8 +113,8 @@ def create_mapping(): else: if success == 'false': xbmcgui.Dialog().ok( - _('name'), - _('mapping_failure') + _('name'), + _('mapping_failure') ) else: return @@ -118,19 +122,92 @@ def create_mapping(): @plugin.route('/actions/pair-host') def pair_host(): - code = launch_moonlight_pair() + pair_dialog = xbmcgui.DialogProgress() + pair_dialog.create( + _('name'), + 'Starting Pairing' + ) + + pairing = subprocess.Popen(['stdbuf', '-oL', Config.get_binary(), 'pair', Config.get_host()], + stdout=subprocess.PIPE) + + lines_iterator = iter(pairing.stdout.readline, b"") + + thread = threading.Thread(target=loop_lines, args=(pair_dialog, lines_iterator)) + thread.start() + + success = False + + while True: + xbmc.sleep(1000) + if not thread.isAlive(): + success = True + break + if pair_dialog.iscanceled(): + pairing.kill() + pair_dialog.close() + success = False + log('Pairing canceled') + break - if len(code) > 1: - line = code[1] - if line == '': - line = _('pair_failure_paired') + if success: + pair_dialog.update(0, 'Checking if pairing has been successful.') + xbmc.sleep(1000) + pairing_check = subprocess.Popen([Config.get_binary(), 'list', Config.get_host()], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + last_line = '' + while True: + line = pairing_check.stdout.readline() + err = pairing_check.stderr.readline() + if line != '': + last_line = line + if err != '': + last_line = err + if not line and not err: + break + + pair_dialog.close() + if last_line.lower().strip() != 'You must pair with the PC first'.lower().strip(): + xbmcgui.Dialog().ok( + _('name'), + 'Successfully paired' + ) + else: + confirmed = xbmcgui.Dialog().yesno( + _('name'), + 'Pairing failed - do you want to try again?' + ) + if confirmed: + pair_host() + else: + return else: - line = code[0] + return - xbmcgui.Dialog().ok( - _('name'), - line + +@plugin.route('/actions/reset-cache') +def reset_cache(): + confirmed = xbmcgui.Dialog().yesno( + _('name'), + 'This will remove all cached game information and clear the game storage. Next time you\'re going to ' + + 'visit the game view it will take some time until all information is available again. ' + + 'Are you sure you want to do this?' ) + if confirmed: + plugin.get_storage('game_storage').clear() + if os.path.exists(addon_path + '/boxarts'): + shutil.rmtree(addon_path + '/boxarts', ignore_errors=True) + log('Deleted boxarts folder on user request') + if os.path.exists(addon_path + '/api_cache'): + shutil.rmtree(addon_path + '/api_cache', ignore_errors=True) + log('Deleted api cache on user request') + xbmcgui.Dialog().ok( + _('name'), + 'Deleted cache.' + ) + else: + return @plugin.route('/games') @@ -140,40 +217,48 @@ def context_menu(): ( _('addon_settings'), 'XBMC.RunPlugin(%s)' % plugin.url_for( - endpoint='open_settings' + endpoint='open_settings' ) ), ( _('full_refresh'), 'XBMC.RunPlugin(%s)' % plugin.url_for( - endpoint='do_full_refresh' + endpoint='do_full_refresh' ) ) ] - Config.dump_conf() - game_storage = plugin.get_storage('game_storage') - game_storage.clear() - games = get_games() + games = plugin.get_storage('game_storage') + + if len(games.raw_dict()) == 0: + get_games() + items = [] - for i, game in enumerate(games): - label = game + for i, game_name in enumerate(games): + game = games.get(game_name) items.append({ - 'label': label, + 'label': game.name, + 'icon': game.thumb, + 'thumbnail': game.thumb, + 'info': { + 'originaltitle': game.name, + 'year': game.year, + 'plot': game.plot, + 'genre': game.genre, + }, 'replace_context_menu': True, 'context_menu': context_menu(), 'path': plugin.url_for( - endpoint='launch_game', - game_id=game + endpoint='launch_game', + game_id=game.name ) }) - game_storage.sync() return plugin.finish(items) @plugin.route('/games/all/refresh') def do_full_refresh(): - return get_games() + get_games() @plugin.route('/games/launch/') @@ -181,24 +266,13 @@ def launch_game(game_id): log('Launching game %s' % game_id) configure_helper(Config, Config.get_binary()) log('Reconfigured helper and dumped conf to disk.') - subprocess.call([addon_internal_path+'/resources/lib/launch-helper-osmc.sh', - addon_internal_path+'/resources/lib/launch.sh', - addon_internal_path+'/resources/lib/moonlight-heartbeat.sh', + subprocess.call([addon_internal_path + '/resources/lib/launch-helper-osmc.sh', + addon_internal_path + '/resources/lib/launch.sh', + addon_internal_path + '/resources/lib/moonlight-heartbeat.sh', game_id, Config.get_config_path()]) -def launch_moonlight_pair(): - code = [] - process = subprocess.Popen([Config.get_binary(), 'pair', Config.get_host()], stdout=subprocess.PIPE) - while True: - line = process.stdout.readline() - code.append(line) - if not line: - break - return code - - def loop_lines(dialog, iterator): for line in iterator: log(line) @@ -207,14 +281,33 @@ def loop_lines(dialog, iterator): def get_games(): game_list = [] + configure_helper(Config, Config.get_binary()) list_proc = subprocess.Popen([Config.get_binary(), 'list', Config.get_host()], stdout=subprocess.PIPE) + while True: line = list_proc.stdout.readline() - log(line[3:]) - game_list.append(line[3:].strip()) + if line[3:] != '': + log(line[3:]) + game_list.append(line[3:].strip()) if not line: break - return game_list + + log('Done getting games from moonlight') + + game_storage = plugin.get_storage('game_storage') + cache = game_storage.raw_dict() + game_storage.clear() + + scraper = ScraperCollection(addon_path) + + for game_name in game_list: + if cache.has_key(game_name): + if not game_storage.get(game_name): + game_storage[game_name] = cache.get(game_name) + else: + game_storage[game_name] = scraper.query_game_information(game_name) + + game_storage.sync() def get_binary(): @@ -237,23 +330,24 @@ def configure_helper(config, binary_path): :param binary_path: string """ config.configure( - addon_path, - binary_path, - plugin.get_setting('host', unicode), - plugin.get_setting('enable_custom_resolution', bool), - plugin.get_setting('resolution_width', str), - plugin.get_setting('resolution_height', str), - plugin.get_setting('resolution', str), - plugin.get_setting('framerate', str), - plugin.get_setting('graphic_optimizations', bool), - plugin.get_setting('remote_optimizations', bool), - plugin.get_setting('local_audio', bool), - plugin.get_setting('enable_custom_bitrate', bool), - plugin.get_setting('bitrate', int), - plugin.get_setting('packetsize', int), - plugin.get_setting('enable_custom_input', bool), - plugin.get_setting('input_map', str), - plugin.get_setting('input_device', str) + addon_path, + binary_path, + plugin.get_setting('host', unicode), + plugin.get_setting('enable_custom_resolution', bool), + plugin.get_setting('resolution_width', str), + plugin.get_setting('resolution_height', str), + plugin.get_setting('resolution', str), + plugin.get_setting('framerate', str), + plugin.get_setting('graphic_optimizations', bool), + plugin.get_setting('remote_optimizations', bool), + plugin.get_setting('local_audio', bool), + plugin.get_setting('enable_custom_bitrate', bool), + plugin.get_setting('bitrate', int), + plugin.get_setting('packetsize', int), + plugin.get_setting('enable_custom_input', bool), + plugin.get_setting('input_map', str), + plugin.get_setting('input_device', str), + plugin.get_setting('override_default_resolution', bool) ) config.dump_conf() @@ -262,19 +356,19 @@ def configure_helper(config, binary_path): def check_script_permissions(): - st = os.stat(addon_internal_path+'/resources/lib/launch.sh') + st = os.stat(addon_internal_path + '/resources/lib/launch.sh') if not bool(st.st_mode & stat.S_IXUSR): - os.chmod(addon_internal_path+'/resources/lib/launch.sh', st.st_mode | 0111) + os.chmod(addon_internal_path + '/resources/lib/launch.sh', st.st_mode | 0111) log('Changed file permissions for launch') - st = os.stat(addon_internal_path+'/resources/lib/launch-helper-osmc.sh') + st = os.stat(addon_internal_path + '/resources/lib/launch-helper-osmc.sh') if not bool(st.st_mode & stat.S_IXUSR): - os.chmod(addon_internal_path+'/resources/lib/launch-helper-osmc.sh', st.st_mode | 0111) + os.chmod(addon_internal_path + '/resources/lib/launch-helper-osmc.sh', st.st_mode | 0111) log('Changed file permissions for launch-helper-osmc') - st = os.stat(addon_internal_path+'/resources/lib/moonlight-heartbeat.sh') + st = os.stat(addon_internal_path + '/resources/lib/moonlight-heartbeat.sh') if not bool(st.st_mode & stat.S_IXUSR): - os.chmod(addon_internal_path+'/resources/lib/moonlight-heartbeat.sh', st.st_mode | 0111) + os.chmod(addon_internal_path + '/resources/lib/moonlight-heartbeat.sh', st.st_mode | 0111) log('Changed file permissions for moonlight-heartbeat') @@ -298,6 +392,6 @@ def _(string_id): plugin.run() else: xbmcgui.Dialog().ok( - _('name'), - _('configure_first') + _('name'), + _('configure_first') ) diff --git a/addon.xml b/addon.xml index 516035f..0b380a3 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -11,6 +11,6 @@ all Moonlight Launcher for Kodi - Moonlight Launcher for Kodi + Moonlight Launcher for Kodi. [CR]Icons made by Freepik (www.flaticon.com/authors/freepik) from www.flaticon.com [CR]Game information and posters are provided by OMDB (http://www.omdbapi.com) under CC-BY4.0 [CR]Additional game information and posters provided by TheGamesDB (http://thegamesdb.net) diff --git a/resources/icons/cog.png b/resources/icons/cog.png new file mode 100644 index 0000000..4354129 Binary files /dev/null and b/resources/icons/cog.png differ diff --git a/resources/icons/controller.png b/resources/icons/controller.png new file mode 100644 index 0000000..2acf96f Binary files /dev/null and b/resources/icons/controller.png differ diff --git a/resources/language/English/strings.xml b/resources/language/English/strings.xml index c02fc26..9549fbe 100644 --- a/resources/language/English/strings.xml +++ b/resources/language/English/strings.xml @@ -23,6 +23,9 @@ Input Map Input Device Create Map for Above Device + Cache Control + Remove Game Cache + Override Default Resolution Addon Settings Full Refresh diff --git a/resources/lib/confighelper.py b/resources/lib/confighelper.py index b9cf4b7..38d33d6 100644 --- a/resources/lib/confighelper.py +++ b/resources/lib/confighelper.py @@ -46,9 +46,11 @@ def _reset(self): self.input_device = None self.full_path = None - def _configure(self, addon_path, binary_path=None, host_ip=None, enable_custom_res=False, resolution_width=None, resolution_height=None, resolution=None, - framerate=None, graphics_optimizations=False, remote_optimizations=False, local_audio=False, enable_custom_bitrate=False, bitrate=None, packetsize=None, - enable_custom_input=False, input_map=None, input_device=None): + def _configure(self, addon_path, binary_path=None, host_ip=None, enable_custom_res=False, resolution_width=None, + resolution_height=None, resolution=None, + framerate=None, graphics_optimizations=False, remote_optimizations=False, local_audio=False, + enable_custom_bitrate=False, bitrate=None, packetsize=None, + enable_custom_input=False, input_map=None, input_device=None, override_default_resolution=False): self.addon_path = addon_path self.binary_path = binary_path @@ -67,31 +69,35 @@ def _configure(self, addon_path, binary_path=None, host_ip=None, enable_custom_r self.enable_custom_input = enable_custom_input self.input_map = input_map self.input_device = input_device + self.override_default_resolution = override_default_resolution self.full_path = ''.join([self.addon_path, conf]) - def configure(self, addon_path, binary_path=None, host_ip=None, enable_custom_res=False, resolution_width=None, resolution_height=None, resolution=None, - framerate=None, graphics_optimizations=False, remote_optimizations=False, local_audio=False, enable_custom_bitrate=False, bitrate=None, packetsize=None, - enable_custom_input=False, input_map=None, input_device=None): + def configure(self, addon_path, binary_path=None, host_ip=None, enable_custom_res=False, resolution_width=None, + resolution_height=None, resolution=None, + framerate=None, graphics_optimizations=False, remote_optimizations=False, local_audio=False, + enable_custom_bitrate=False, bitrate=None, packetsize=None, + enable_custom_input=False, input_map=None, input_device=None, override_default_resolution=False): self._configure( - addon_path, - binary_path, - host_ip, - enable_custom_res, - resolution_width, - resolution_height, - resolution, - framerate, - graphics_optimizations, - remote_optimizations, - local_audio, - enable_custom_bitrate, - bitrate, - packetsize, - enable_custom_input, - input_map, - input_device + addon_path, + binary_path, + host_ip, + enable_custom_res, + resolution_width, + resolution_height, + resolution, + framerate, + graphics_optimizations, + remote_optimizations, + local_audio, + enable_custom_bitrate, + bitrate, + packetsize, + enable_custom_input, + input_map, + input_device, + override_default_resolution ) def dump_conf(self): @@ -107,17 +113,23 @@ def dump_conf(self): config.set('General', 'binpath', self.binary_path) config.set('General', 'address', self.host_ip) - if self.enable_custom_res: - config.set('General', 'width', int(self.resolution_width[0])) - config.set('General', 'height', int(self.resolution_height[0])) - + if not self.override_default_resolution: + if config.has_option('General', 'height'): + config.remove_option('General', 'height') + if config.has_option('General', 'width'): + config.remove_option('General', 'width') else: - if self.resolution == '1920x1080': - config.set('General', 'width', 1920) - config.set('General', 'height', 1080) - if self.resolution == '1280x720': - config.set('General', 'width', 1280) - config.set('General', 'height', 720) + if self.enable_custom_res: + config.set('General', 'width', int(self.resolution_width[0])) + config.set('General', 'height', int(self.resolution_height[0])) + + else: + if self.resolution == '1920x1080': + config.set('General', 'width', 1920) + config.set('General', 'height', 1080) + if self.resolution == '1280x720': + config.set('General', 'width', 1280) + config.set('General', 'height', 720) config.set('General', 'fps', self.framerate) if self.enable_custom_bitrate: diff --git a/resources/lib/game.py b/resources/lib/game.py new file mode 100644 index 0000000..e5ca4fe --- /dev/null +++ b/resources/lib/game.py @@ -0,0 +1,13 @@ +class Game: + def __init__(self, name, json): + self.name = name + if json is not None: + self.year = json['Year'] + self.genre = json['Genre'] + self.plot = json['Plot'] + self.thumb = json['Poster'] + else: + self.year = '' + self.genre = '' + self.plot = '' + self.thumb = 'N/A' diff --git a/resources/lib/launch.sh b/resources/lib/launch.sh index a35bcfb..9cabbdd 100644 --- a/resources/lib/launch.sh +++ b/resources/lib/launch.sh @@ -3,4 +3,4 @@ GAME=$1 CONF_PATH=$2 -sudo moonlight stream -app "${GAME}" -config ${CONF_PATH} +moonlight stream -app "${GAME}" -config "${CONF_PATH}" diff --git a/resources/lib/scraper.py b/resources/lib/scraper.py new file mode 100644 index 0000000..fafe061 --- /dev/null +++ b/resources/lib/scraper.py @@ -0,0 +1,147 @@ +import json +import os +import subprocess +import urllib2 +import xml.etree.ElementTree as ET + +from game import Game + + +class ScraperCollection: + def __init__(self, addon_path): + _configure(addon_path) + self.omdb = OmdbScraper() + self.tgdb = TgdbScraper(addon_path) + self.img_path = addon_path + '/boxarts/' + + def query_game_information(self, game_name): + request_name = game_name.replace(" ", "+").replace(":", "") + game = _get_information(self, request_name) + game.name = game_name + return game + + +class OmdbScraper: + def __init__(self): + self.api_url = 'http://www.omdbapi.com/?t=%s&plot=short&r=json&type=game' + + +class TgdbScraper: + def __init__(self, addon_path): + self.api_url = 'http://thegamesdb.net/api/GetGame.php?name=%s' + self.api_cache = addon_path + '/api_cache/' + + +def _configure(addon_path): + if not os.path.exists(addon_path + '/boxarts'): + os.makedirs(addon_path + '/boxarts') + if not os.path.exists(addon_path + '/api_cache'): + os.makedirs(addon_path + '/api_cache') + + +def _get_information(self, game_name): + """ + + :type self: ScraperCollection + :rtype Game + """ + if game_name == 'Steam': + return Game(game_name, None) + + omdb_response = json.load(urllib2.urlopen(self.omdb.api_url % game_name)) + + if omdb_response['Response'] == 'False': + file_path = self.tgdb.api_cache + game_name + '.xml' + _cache_tgdb_response_data(self, file_path, game_name) + + root = ET.ElementTree(file=file_path).getroot() + + omdb_response = _parse_xml(root) + + full_img_url = omdb_response['Poster'] + full_img_path = self.img_path + os.path.basename(full_img_url) + + else: + if omdb_response['Poster'] == 'N/A': + file_path = self.tgdb.api_cache + game_name + '.xml' + _cache_tgdb_response_data(self, file_path, game_name) + + root = ET.ElementTree(file=file_path).getroot() + + full_img_url = _build_image_path(_get_img_base_url(root), _get_image(root)) + full_img_path = self.img_path + os.path.basename(full_img_url) + else: + full_img_url = omdb_response['Poster'] + full_img_path = self.img_path + os.path.basename(full_img_url) + + _dump_image(full_img_path, full_img_url) + omdb_response['Poster'] = full_img_path + + return Game(game_name, omdb_response) + + +def _cache_tgdb_response_data(self, file_path, name): + """ + + :type self: ScraperCollection + """ + if not os.path.isfile(file_path): + curl = subprocess.Popen(['curl', '-XGET', self.tgdb.api_url % name], stdout=subprocess.PIPE) + with open(file_path, 'w') as response_file: + response_file.write(curl.stdout.read()) + + +def _get_img_base_url(r): + return r.find('baseImgUrl').text + + +def _get_image(r): + for i in r.findall('Game'): + if i.find('Platform').text == 'PC': + for b in i.find('Images'): + if b.get('side') == 'front': + return b.text + return None + + +def _parse_xml(r): + data = {} + img_base_url = _get_img_base_url(r) + for i in r.findall('Game'): + if i.find('Platform').text == 'PC': + if i.find('ReleaseDate'): + data['Year'] = os.path.basename(i.find('ReleaseDate').text) + else: + data['Year'] = 'N/A' + if i.find('Overview'): + data['Plot'] = i.find('Overview').text + else: + data['Plot'] = 'N/A' + data['Poster'] = 'N/A' + for b in i.find('Images'): + if b.get('side') == 'front': + data['Poster'] = img_base_url + b.text + if not i.find('Genres'): + data['Genre'] = 'N/A' + else: + for g in i.find('Genres'): + data['Genre'] = ', '.join([data['Genre'], g.text]) + break + return data + + +def _build_image_path(base_url, img_url=None): + if base_url is not None and img_url is not None: + return base_url + img_url + if base_url is not None: + return base_url + else: + return None + + +def _dump_image(path, url): + if not os.path.exists(path): + with open(path, 'wb') as img: + img_curl = subprocess.Popen(['curl', '-XGET', url], stdout=subprocess.PIPE) + img.write(img_curl.stdout.read()) + img.close() diff --git a/resources/settings.xml b/resources/settings.xml index 480f986..be51ca5 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -4,8 +4,9 @@ - - + + + @@ -23,4 +24,7 @@ + + +