├── .gitignore ├── GamesList.txt ├── LICENSE ├── README.md ├── backend.py ├── files └── gametimes.xml ├── fuzzywuzzy ├── StringMatcher.py ├── __init__.py ├── __pycache__ │ ├── StringMatcher.cpython-37.pyc │ ├── fuzz.cpython-37.pyc │ └── process.cpython-37.pyc ├── fuzz.py ├── process.py ├── string_processing.py └── utils.py ├── galaxy ├── __init__.py ├── api │ ├── __init__.py │ ├── consts.py │ ├── errors.py │ ├── jsonrpc.py │ ├── plugin.py │ └── types.py ├── http.py ├── proc_tools.py ├── reader.py ├── registry_monitor.py ├── task_manager.py ├── tools.py └── unittest │ ├── __init__.py │ └── mock.py ├── games.xml ├── manifest.json ├── plugin.py ├── user_config.py └── version.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Config file 2 | config.py 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Joshua Oki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nintendo Wii GOG Galaxy 2.0 Integration 2 | 3 | A GOG Galaxy 2.0 integration with the Dolphin emulator. 4 | This is a fork of AHCoder (on GitHub)'s PCSX2 GOG Galaxy Plugin edited for support with Dolphin! 5 | If you want to download it go here: 6 | 7 | 8 | Thank you AHCoder for the original program, and the Index file 9 | which gives the game database is from GameTDB. 10 | 11 | # Setup: 12 | Just download the file here and extract the ZIP into: ```C:\Users\\AppData\Local\GOG.com\Galaxy\plugins\installed``` 13 | 14 | Edit user_config.py with your ROMs and Dolphin location. 15 | 16 | Go into GOG Galaxy 2.0, click on integrations and connect the one with "Nintendo Wii" and you're done. 17 | 18 | # Limitations: 19 | 20 | All ROMs must be in the same folder. Subfolders are allowed. 21 | 22 | If you have the game's ID in the filename you can enable ```match_by_id```. Using only this option means that any file without ID falls back to exact match between filename and game name in GamesList.txt. 23 | 24 | Enable ```best_match_game_detection``` to allow the best match algorithm. It can work as a fallback with ```match_by_id```. 25 | 26 | If you enable none of the above options the name of the ROM must be equivalent to its counterpart in GamesList.txt. You can look up the name in GamesList.txt and edit your file accordingly. 27 | 28 | Supported file extensions are ISO, CISO, GCM, GCZ, WAD, WBFS, WIA, and RVZ. 29 | -------------------------------------------------------------------------------- /backend.py: -------------------------------------------------------------------------------- 1 | import os 2 | import user_config 3 | from galaxy.api.consts import LocalGameState 4 | from galaxy.api.types import LocalGame 5 | from xml.etree import ElementTree 6 | from fuzzywuzzy import fuzz 7 | 8 | class WiiGame: 9 | 10 | def __init__(self, path, id, name): 11 | self.path = path 12 | self.id = id 13 | self.name = name 14 | 15 | class BackendClient: 16 | 17 | def __init__(self): 18 | pass 19 | 20 | def get_games_db(self): 21 | games = [] 22 | paths = self._get_rom_paths() 23 | database_records = self._parse_dbf() 24 | 25 | for p in paths: 26 | best_record = [] 27 | best_ratio = 0 28 | file_name = os.path.splitext(os.path.basename(p))[0] 29 | matched = False 30 | for record in database_records: 31 | if user_config.match_by_id and record["id"] in file_name.upper(): 32 | games.append(WiiGame(p, record["id"], record["name"])) 33 | matched = True 34 | elif user_config.best_match_game_detection: 35 | current_ratio = fuzz.token_sort_ratio(file_name, record[ 36 | "name"]) # Calculate the ratio of the name with the current record 37 | if current_ratio > best_ratio: 38 | best_ratio = current_ratio 39 | best_record = record 40 | else: 41 | # User wants exact match 42 | if file_name == record["name"]: 43 | games.append(WiiGame(p, record["id"], record["name"])) 44 | 45 | # Save the best record that matched the game 46 | if user_config.best_match_game_detection and not matched: 47 | games.append(WiiGame(p, best_record["id"], best_record["name"])) 48 | return games 49 | 50 | def _parse_dbf(self): 51 | file = ElementTree.parse(os.path.dirname(os.path.realpath(__file__)) + r'\games.xml') 52 | games_xml = file.getroot() 53 | games = games_xml.findall('game') 54 | records = [] 55 | for game in games: 56 | game_id = game.find('id').text 57 | game_platform = game.find('type').text 58 | locale = game.find('locale') 59 | game_name = locale.find('title').text 60 | if game_platform != "GameCube": 61 | records.append({"id": game_id, "name": game_name}) 62 | return records 63 | 64 | def _get_rom_paths(self): 65 | paths = [] 66 | # Search through directory for Dolphin ROMs 67 | for root, _, files in os.walk(user_config.roms_path): 68 | for file in files: 69 | if any(file.lower().endswith(ext) for ext in (".iso", ".ciso", ".gcm", ".gcz", ".wad", ".wbfs", ".wia", ".rvz")): 70 | paths.append(os.path.join(root, file)) 71 | return paths 72 | 73 | def get_state_changes(self, old_list, new_list): 74 | old_dict = {x.game_id: x.local_game_state for x in old_list} 75 | new_dict = {x.game_id: x.local_game_state for x in new_list} 76 | result = [] 77 | # removed games 78 | result.extend(LocalGame(id, LocalGameState.None_) for id in old_dict.keys() - new_dict.keys()) 79 | # added games 80 | result.extend(local_game for local_game in new_list if local_game.game_id in new_dict.keys() - old_dict.keys()) 81 | # state changed 82 | result.extend(LocalGame(id, new_dict[id]) for id in new_dict.keys() & old_dict.keys() if new_dict[id] != old_dict[id]) 83 | return result 84 | 85 | if __name__ == "__main__": 86 | bc = BackendClient() 87 | for game in bc.get_games_db(): 88 | print(game.id) 89 | print(game.name) 90 | print(game.path) 91 | print() 92 | -------------------------------------------------------------------------------- /fuzzywuzzy/StringMatcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | StringMatcher.py 5 | 6 | ported from python-Levenshtein 7 | [https://github.com/miohtama/python-Levenshtein] 8 | License available here: https://github.com/miohtama/python-Levenshtein/blob/master/COPYING 9 | """ 10 | 11 | from Levenshtein import * 12 | from warnings import warn 13 | 14 | 15 | class StringMatcher: 16 | """A SequenceMatcher-like class built on the top of Levenshtein""" 17 | 18 | def _reset_cache(self): 19 | self._ratio = self._distance = None 20 | self._opcodes = self._editops = self._matching_blocks = None 21 | 22 | def __init__(self, isjunk=None, seq1='', seq2=''): 23 | if isjunk: 24 | warn("isjunk not NOT implemented, it will be ignored") 25 | self._str1, self._str2 = seq1, seq2 26 | self._reset_cache() 27 | 28 | def set_seqs(self, seq1, seq2): 29 | self._str1, self._str2 = seq1, seq2 30 | self._reset_cache() 31 | 32 | def set_seq1(self, seq1): 33 | self._str1 = seq1 34 | self._reset_cache() 35 | 36 | def set_seq2(self, seq2): 37 | self._str2 = seq2 38 | self._reset_cache() 39 | 40 | def get_opcodes(self): 41 | if not self._opcodes: 42 | if self._editops: 43 | self._opcodes = opcodes(self._editops, self._str1, self._str2) 44 | else: 45 | self._opcodes = opcodes(self._str1, self._str2) 46 | return self._opcodes 47 | 48 | def get_editops(self): 49 | if not self._editops: 50 | if self._opcodes: 51 | self._editops = editops(self._opcodes, self._str1, self._str2) 52 | else: 53 | self._editops = editops(self._str1, self._str2) 54 | return self._editops 55 | 56 | def get_matching_blocks(self): 57 | if not self._matching_blocks: 58 | self._matching_blocks = matching_blocks(self.get_opcodes(), 59 | self._str1, self._str2) 60 | return self._matching_blocks 61 | 62 | def ratio(self): 63 | if not self._ratio: 64 | self._ratio = ratio(self._str1, self._str2) 65 | return self._ratio 66 | 67 | def quick_ratio(self): 68 | # This is usually quick enough :o) 69 | if not self._ratio: 70 | self._ratio = ratio(self._str1, self._str2) 71 | return self._ratio 72 | 73 | def real_quick_ratio(self): 74 | len1, len2 = len(self._str1), len(self._str2) 75 | return 2.0 * min(len1, len2) / (len1 + len2) 76 | 77 | def distance(self): 78 | if not self._distance: 79 | self._distance = distance(self._str1, self._str2) 80 | return self._distance 81 | -------------------------------------------------------------------------------- /fuzzywuzzy/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version__ = '0.17.0' 3 | -------------------------------------------------------------------------------- /fuzzywuzzy/__pycache__/StringMatcher.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JTNDev/galaxy-integration-wii/a8fb27925c3003997a06c2384a3255bb1b4f7786/fuzzywuzzy/__pycache__/StringMatcher.cpython-37.pyc -------------------------------------------------------------------------------- /fuzzywuzzy/__pycache__/fuzz.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JTNDev/galaxy-integration-wii/a8fb27925c3003997a06c2384a3255bb1b4f7786/fuzzywuzzy/__pycache__/fuzz.cpython-37.pyc -------------------------------------------------------------------------------- /fuzzywuzzy/__pycache__/process.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JTNDev/galaxy-integration-wii/a8fb27925c3003997a06c2384a3255bb1b4f7786/fuzzywuzzy/__pycache__/process.cpython-37.pyc -------------------------------------------------------------------------------- /fuzzywuzzy/fuzz.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | from __future__ import unicode_literals 4 | import platform 5 | import warnings 6 | 7 | try: 8 | from .StringMatcher import StringMatcher as SequenceMatcher 9 | except ImportError: 10 | if platform.python_implementation() != "PyPy": 11 | warnings.warn('Using slow pure-python SequenceMatcher. Install python-Levenshtein to remove this warning') 12 | from difflib import SequenceMatcher 13 | 14 | from . import utils 15 | 16 | 17 | ########################### 18 | # Basic Scoring Functions # 19 | ########################### 20 | 21 | @utils.check_for_none 22 | @utils.check_for_equivalence 23 | @utils.check_empty_string 24 | def ratio(s1, s2): 25 | s1, s2 = utils.make_type_consistent(s1, s2) 26 | 27 | m = SequenceMatcher(None, s1, s2) 28 | return utils.intr(100 * m.ratio()) 29 | 30 | 31 | @utils.check_for_none 32 | @utils.check_for_equivalence 33 | @utils.check_empty_string 34 | def partial_ratio(s1, s2): 35 | """"Return the ratio of the most similar substring 36 | as a number between 0 and 100.""" 37 | s1, s2 = utils.make_type_consistent(s1, s2) 38 | 39 | if len(s1) <= len(s2): 40 | shorter = s1 41 | longer = s2 42 | else: 43 | shorter = s2 44 | longer = s1 45 | 46 | m = SequenceMatcher(None, shorter, longer) 47 | blocks = m.get_matching_blocks() 48 | 49 | # each block represents a sequence of matching characters in a string 50 | # of the form (idx_1, idx_2, len) 51 | # the best partial match will block align with at least one of those blocks 52 | # e.g. shorter = "abcd", longer = XXXbcdeEEE 53 | # block = (1,3,3) 54 | # best score === ratio("abcd", "Xbcd") 55 | scores = [] 56 | for block in blocks: 57 | long_start = block[1] - block[0] if (block[1] - block[0]) > 0 else 0 58 | long_end = long_start + len(shorter) 59 | long_substr = longer[long_start:long_end] 60 | 61 | m2 = SequenceMatcher(None, shorter, long_substr) 62 | r = m2.ratio() 63 | if r > .995: 64 | return 100 65 | else: 66 | scores.append(r) 67 | 68 | return utils.intr(100 * max(scores)) 69 | 70 | 71 | ############################## 72 | # Advanced Scoring Functions # 73 | ############################## 74 | 75 | def _process_and_sort(s, force_ascii, full_process=True): 76 | """Return a cleaned string with token sorted.""" 77 | # pull tokens 78 | ts = utils.full_process(s, force_ascii=force_ascii) if full_process else s 79 | tokens = ts.split() 80 | 81 | # sort tokens and join 82 | sorted_string = u" ".join(sorted(tokens)) 83 | return sorted_string.strip() 84 | 85 | 86 | # Sorted Token 87 | # find all alphanumeric tokens in the string 88 | # sort those tokens and take ratio of resulting joined strings 89 | # controls for unordered string elements 90 | @utils.check_for_none 91 | def _token_sort(s1, s2, partial=True, force_ascii=True, full_process=True): 92 | sorted1 = _process_and_sort(s1, force_ascii, full_process=full_process) 93 | sorted2 = _process_and_sort(s2, force_ascii, full_process=full_process) 94 | 95 | if partial: 96 | return partial_ratio(sorted1, sorted2) 97 | else: 98 | return ratio(sorted1, sorted2) 99 | 100 | 101 | def token_sort_ratio(s1, s2, force_ascii=True, full_process=True): 102 | """Return a measure of the sequences' similarity between 0 and 100 103 | but sorting the token before comparing. 104 | """ 105 | return _token_sort(s1, s2, partial=False, force_ascii=force_ascii, full_process=full_process) 106 | 107 | 108 | def partial_token_sort_ratio(s1, s2, force_ascii=True, full_process=True): 109 | """Return the ratio of the most similar substring as a number between 110 | 0 and 100 but sorting the token before comparing. 111 | """ 112 | return _token_sort(s1, s2, partial=True, force_ascii=force_ascii, full_process=full_process) 113 | 114 | 115 | @utils.check_for_none 116 | def _token_set(s1, s2, partial=True, force_ascii=True, full_process=True): 117 | """Find all alphanumeric tokens in each string... 118 | - treat them as a set 119 | - construct two strings of the form: 120 | 121 | - take ratios of those two strings 122 | - controls for unordered partial matches""" 123 | 124 | if not full_process and s1 == s2: 125 | return 100 126 | 127 | p1 = utils.full_process(s1, force_ascii=force_ascii) if full_process else s1 128 | p2 = utils.full_process(s2, force_ascii=force_ascii) if full_process else s2 129 | 130 | if not utils.validate_string(p1): 131 | return 0 132 | if not utils.validate_string(p2): 133 | return 0 134 | 135 | # pull tokens 136 | tokens1 = set(p1.split()) 137 | tokens2 = set(p2.split()) 138 | 139 | intersection = tokens1.intersection(tokens2) 140 | diff1to2 = tokens1.difference(tokens2) 141 | diff2to1 = tokens2.difference(tokens1) 142 | 143 | sorted_sect = " ".join(sorted(intersection)) 144 | sorted_1to2 = " ".join(sorted(diff1to2)) 145 | sorted_2to1 = " ".join(sorted(diff2to1)) 146 | 147 | combined_1to2 = sorted_sect + " " + sorted_1to2 148 | combined_2to1 = sorted_sect + " " + sorted_2to1 149 | 150 | # strip 151 | sorted_sect = sorted_sect.strip() 152 | combined_1to2 = combined_1to2.strip() 153 | combined_2to1 = combined_2to1.strip() 154 | 155 | if partial: 156 | ratio_func = partial_ratio 157 | else: 158 | ratio_func = ratio 159 | 160 | pairwise = [ 161 | ratio_func(sorted_sect, combined_1to2), 162 | ratio_func(sorted_sect, combined_2to1), 163 | ratio_func(combined_1to2, combined_2to1) 164 | ] 165 | return max(pairwise) 166 | 167 | 168 | def token_set_ratio(s1, s2, force_ascii=True, full_process=True): 169 | return _token_set(s1, s2, partial=False, force_ascii=force_ascii, full_process=full_process) 170 | 171 | 172 | def partial_token_set_ratio(s1, s2, force_ascii=True, full_process=True): 173 | return _token_set(s1, s2, partial=True, force_ascii=force_ascii, full_process=full_process) 174 | 175 | 176 | ################### 177 | # Combination API # 178 | ################### 179 | 180 | # q is for quick 181 | def QRatio(s1, s2, force_ascii=True, full_process=True): 182 | """ 183 | Quick ratio comparison between two strings. 184 | 185 | Runs full_process from utils on both strings 186 | Short circuits if either of the strings is empty after processing. 187 | 188 | :param s1: 189 | :param s2: 190 | :param force_ascii: Allow only ASCII characters (Default: True) 191 | :full_process: Process inputs, used here to avoid double processing in extract functions (Default: True) 192 | :return: similarity ratio 193 | """ 194 | 195 | if full_process: 196 | p1 = utils.full_process(s1, force_ascii=force_ascii) 197 | p2 = utils.full_process(s2, force_ascii=force_ascii) 198 | else: 199 | p1 = s1 200 | p2 = s2 201 | 202 | if not utils.validate_string(p1): 203 | return 0 204 | if not utils.validate_string(p2): 205 | return 0 206 | 207 | return ratio(p1, p2) 208 | 209 | 210 | def UQRatio(s1, s2, full_process=True): 211 | """ 212 | Unicode quick ratio 213 | 214 | Calls QRatio with force_ascii set to False 215 | 216 | :param s1: 217 | :param s2: 218 | :return: similarity ratio 219 | """ 220 | return QRatio(s1, s2, force_ascii=False, full_process=full_process) 221 | 222 | 223 | # w is for weighted 224 | def WRatio(s1, s2, force_ascii=True, full_process=True): 225 | """ 226 | Return a measure of the sequences' similarity between 0 and 100, using different algorithms. 227 | 228 | **Steps in the order they occur** 229 | 230 | #. Run full_process from utils on both strings 231 | #. Short circuit if this makes either string empty 232 | #. Take the ratio of the two processed strings (fuzz.ratio) 233 | #. Run checks to compare the length of the strings 234 | * If one of the strings is more than 1.5 times as long as the other 235 | use partial_ratio comparisons - scale partial results by 0.9 236 | (this makes sure only full results can return 100) 237 | * If one of the strings is over 8 times as long as the other 238 | instead scale by 0.6 239 | 240 | #. Run the other ratio functions 241 | * if using partial ratio functions call partial_ratio, 242 | partial_token_sort_ratio and partial_token_set_ratio 243 | scale all of these by the ratio based on length 244 | * otherwise call token_sort_ratio and token_set_ratio 245 | * all token based comparisons are scaled by 0.95 246 | (on top of any partial scalars) 247 | 248 | #. Take the highest value from these results 249 | round it and return it as an integer. 250 | 251 | :param s1: 252 | :param s2: 253 | :param force_ascii: Allow only ascii characters 254 | :type force_ascii: bool 255 | :full_process: Process inputs, used here to avoid double processing in extract functions (Default: True) 256 | :return: 257 | """ 258 | 259 | if full_process: 260 | p1 = utils.full_process(s1, force_ascii=force_ascii) 261 | p2 = utils.full_process(s2, force_ascii=force_ascii) 262 | else: 263 | p1 = s1 264 | p2 = s2 265 | 266 | if not utils.validate_string(p1): 267 | return 0 268 | if not utils.validate_string(p2): 269 | return 0 270 | 271 | # should we look at partials? 272 | try_partial = True 273 | unbase_scale = .95 274 | partial_scale = .90 275 | 276 | base = ratio(p1, p2) 277 | len_ratio = float(max(len(p1), len(p2))) / min(len(p1), len(p2)) 278 | 279 | # if strings are similar length, don't use partials 280 | if len_ratio < 1.5: 281 | try_partial = False 282 | 283 | # if one string is much much shorter than the other 284 | if len_ratio > 8: 285 | partial_scale = .6 286 | 287 | if try_partial: 288 | partial = partial_ratio(p1, p2) * partial_scale 289 | ptsor = partial_token_sort_ratio(p1, p2, full_process=False) \ 290 | * unbase_scale * partial_scale 291 | ptser = partial_token_set_ratio(p1, p2, full_process=False) \ 292 | * unbase_scale * partial_scale 293 | 294 | return utils.intr(max(base, partial, ptsor, ptser)) 295 | else: 296 | tsor = token_sort_ratio(p1, p2, full_process=False) * unbase_scale 297 | tser = token_set_ratio(p1, p2, full_process=False) * unbase_scale 298 | 299 | return utils.intr(max(base, tsor, tser)) 300 | 301 | 302 | def UWRatio(s1, s2, full_process=True): 303 | """Return a measure of the sequences' similarity between 0 and 100, 304 | using different algorithms. Same as WRatio but preserving unicode. 305 | """ 306 | return WRatio(s1, s2, force_ascii=False, full_process=full_process) 307 | -------------------------------------------------------------------------------- /fuzzywuzzy/process.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | from . import fuzz 4 | from . import utils 5 | import heapq 6 | import logging 7 | from functools import partial 8 | 9 | 10 | default_scorer = fuzz.WRatio 11 | 12 | 13 | default_processor = utils.full_process 14 | 15 | 16 | def extractWithoutOrder(query, choices, processor=default_processor, scorer=default_scorer, score_cutoff=0): 17 | """Select the best match in a list or dictionary of choices. 18 | 19 | Find best matches in a list or dictionary of choices, return a 20 | generator of tuples containing the match and its score. If a dictionary 21 | is used, also returns the key for each match. 22 | 23 | Arguments: 24 | query: An object representing the thing we want to find. 25 | choices: An iterable or dictionary-like object containing choices 26 | to be matched against the query. Dictionary arguments of 27 | {key: value} pairs will attempt to match the query against 28 | each value. 29 | processor: Optional function of the form f(a) -> b, where a is the query or 30 | individual choice and b is the choice to be used in matching. 31 | 32 | This can be used to match against, say, the first element of 33 | a list: 34 | 35 | lambda x: x[0] 36 | 37 | Defaults to fuzzywuzzy.utils.full_process(). 38 | scorer: Optional function for scoring matches between the query and 39 | an individual processed choice. This should be a function 40 | of the form f(query, choice) -> int. 41 | 42 | By default, fuzz.WRatio() is used and expects both query and 43 | choice to be strings. 44 | score_cutoff: Optional argument for score threshold. No matches with 45 | a score less than this number will be returned. Defaults to 0. 46 | 47 | Returns: 48 | Generator of tuples containing the match and its score. 49 | 50 | If a list is used for choices, then the result will be 2-tuples. 51 | If a dictionary is used, then the result will be 3-tuples containing 52 | the key for each match. 53 | 54 | For example, searching for 'bird' in the dictionary 55 | 56 | {'bard': 'train', 'dog': 'man'} 57 | 58 | may return 59 | 60 | ('train', 22, 'bard'), ('man', 0, 'dog') 61 | """ 62 | # Catch generators without lengths 63 | def no_process(x): 64 | return x 65 | 66 | try: 67 | if choices is None or len(choices) == 0: 68 | raise StopIteration 69 | except TypeError: 70 | pass 71 | 72 | # If the processor was removed by setting it to None 73 | # perfom a noop as it still needs to be a function 74 | if processor is None: 75 | processor = no_process 76 | 77 | # Run the processor on the input query. 78 | processed_query = processor(query) 79 | 80 | if len(processed_query) == 0: 81 | logging.warning(u"Applied processor reduces input query to empty string, " 82 | "all comparisons will have score 0. " 83 | "[Query: \'{0}\']".format(query)) 84 | 85 | # Don't run full_process twice 86 | if scorer in [fuzz.WRatio, fuzz.QRatio, 87 | fuzz.token_set_ratio, fuzz.token_sort_ratio, 88 | fuzz.partial_token_set_ratio, fuzz.partial_token_sort_ratio, 89 | fuzz.UWRatio, fuzz.UQRatio] \ 90 | and processor == utils.full_process: 91 | processor = no_process 92 | 93 | # Only process the query once instead of for every choice 94 | if scorer in [fuzz.UWRatio, fuzz.UQRatio]: 95 | pre_processor = partial(utils.full_process, force_ascii=False) 96 | scorer = partial(scorer, full_process=False) 97 | elif scorer in [fuzz.WRatio, fuzz.QRatio, 98 | fuzz.token_set_ratio, fuzz.token_sort_ratio, 99 | fuzz.partial_token_set_ratio, fuzz.partial_token_sort_ratio]: 100 | pre_processor = partial(utils.full_process, force_ascii=True) 101 | scorer = partial(scorer, full_process=False) 102 | else: 103 | pre_processor = no_process 104 | processed_query = pre_processor(processed_query) 105 | 106 | try: 107 | # See if choices is a dictionary-like object. 108 | for key, choice in choices.items(): 109 | processed = pre_processor(processor(choice)) 110 | score = scorer(processed_query, processed) 111 | if score >= score_cutoff: 112 | yield (choice, score, key) 113 | except AttributeError: 114 | # It's a list; just iterate over it. 115 | for choice in choices: 116 | processed = pre_processor(processor(choice)) 117 | score = scorer(processed_query, processed) 118 | if score >= score_cutoff: 119 | yield (choice, score) 120 | 121 | 122 | def extract(query, choices, processor=default_processor, scorer=default_scorer, limit=5): 123 | """Select the best match in a list or dictionary of choices. 124 | 125 | Find best matches in a list or dictionary of choices, return a 126 | list of tuples containing the match and its score. If a dictionary 127 | is used, also returns the key for each match. 128 | 129 | Arguments: 130 | query: An object representing the thing we want to find. 131 | choices: An iterable or dictionary-like object containing choices 132 | to be matched against the query. Dictionary arguments of 133 | {key: value} pairs will attempt to match the query against 134 | each value. 135 | processor: Optional function of the form f(a) -> b, where a is the query or 136 | individual choice and b is the choice to be used in matching. 137 | 138 | This can be used to match against, say, the first element of 139 | a list: 140 | 141 | lambda x: x[0] 142 | 143 | Defaults to fuzzywuzzy.utils.full_process(). 144 | scorer: Optional function for scoring matches between the query and 145 | an individual processed choice. This should be a function 146 | of the form f(query, choice) -> int. 147 | By default, fuzz.WRatio() is used and expects both query and 148 | choice to be strings. 149 | limit: Optional maximum for the number of elements returned. Defaults 150 | to 5. 151 | 152 | Returns: 153 | List of tuples containing the match and its score. 154 | 155 | If a list is used for choices, then the result will be 2-tuples. 156 | If a dictionary is used, then the result will be 3-tuples containing 157 | the key for each match. 158 | 159 | For example, searching for 'bird' in the dictionary 160 | 161 | {'bard': 'train', 'dog': 'man'} 162 | 163 | may return 164 | 165 | [('train', 22, 'bard'), ('man', 0, 'dog')] 166 | """ 167 | sl = extractWithoutOrder(query, choices, processor, scorer) 168 | return heapq.nlargest(limit, sl, key=lambda i: i[1]) if limit is not None else \ 169 | sorted(sl, key=lambda i: i[1], reverse=True) 170 | 171 | 172 | def extractBests(query, choices, processor=default_processor, scorer=default_scorer, score_cutoff=0, limit=5): 173 | """Get a list of the best matches to a collection of choices. 174 | 175 | Convenience function for getting the choices with best scores. 176 | 177 | Args: 178 | query: A string to match against 179 | choices: A list or dictionary of choices, suitable for use with 180 | extract(). 181 | processor: Optional function for transforming choices before matching. 182 | See extract(). 183 | scorer: Scoring function for extract(). 184 | score_cutoff: Optional argument for score threshold. No matches with 185 | a score less than this number will be returned. Defaults to 0. 186 | limit: Optional maximum for the number of elements returned. Defaults 187 | to 5. 188 | 189 | Returns: A a list of (match, score) tuples. 190 | """ 191 | 192 | best_list = extractWithoutOrder(query, choices, processor, scorer, score_cutoff) 193 | return heapq.nlargest(limit, best_list, key=lambda i: i[1]) if limit is not None else \ 194 | sorted(best_list, key=lambda i: i[1], reverse=True) 195 | 196 | 197 | def extractOne(query, choices, processor=default_processor, scorer=default_scorer, score_cutoff=0): 198 | """Find the single best match above a score in a list of choices. 199 | 200 | This is a convenience method which returns the single best choice. 201 | See extract() for the full arguments list. 202 | 203 | Args: 204 | query: A string to match against 205 | choices: A list or dictionary of choices, suitable for use with 206 | extract(). 207 | processor: Optional function for transforming choices before matching. 208 | See extract(). 209 | scorer: Scoring function for extract(). 210 | score_cutoff: Optional argument for score threshold. If the best 211 | match is found, but it is not greater than this number, then 212 | return None anyway ("not a good enough match"). Defaults to 0. 213 | 214 | Returns: 215 | A tuple containing a single match and its score, if a match 216 | was found that was above score_cutoff. Otherwise, returns None. 217 | """ 218 | best_list = extractWithoutOrder(query, choices, processor, scorer, score_cutoff) 219 | try: 220 | return max(best_list, key=lambda i: i[1]) 221 | except ValueError: 222 | return None 223 | 224 | 225 | def dedupe(contains_dupes, threshold=70, scorer=fuzz.token_set_ratio): 226 | """This convenience function takes a list of strings containing duplicates and uses fuzzy matching to identify 227 | and remove duplicates. Specifically, it uses the process.extract to identify duplicates that 228 | score greater than a user defined threshold. Then, it looks for the longest item in the duplicate list 229 | since we assume this item contains the most entity information and returns that. It breaks string 230 | length ties on an alphabetical sort. 231 | 232 | Note: as the threshold DECREASES the number of duplicates that are found INCREASES. This means that the 233 | returned deduplicated list will likely be shorter. Raise the threshold for fuzzy_dedupe to be less 234 | sensitive. 235 | 236 | Args: 237 | contains_dupes: A list of strings that we would like to dedupe. 238 | threshold: the numerical value (0,100) point at which we expect to find duplicates. 239 | Defaults to 70 out of 100 240 | scorer: Optional function for scoring matches between the query and 241 | an individual processed choice. This should be a function 242 | of the form f(query, choice) -> int. 243 | By default, fuzz.token_set_ratio() is used and expects both query and 244 | choice to be strings. 245 | 246 | Returns: 247 | A deduplicated list. For example: 248 | 249 | In: contains_dupes = ['Frodo Baggin', 'Frodo Baggins', 'F. Baggins', 'Samwise G.', 'Gandalf', 'Bilbo Baggins'] 250 | In: fuzzy_dedupe(contains_dupes) 251 | Out: ['Frodo Baggins', 'Samwise G.', 'Bilbo Baggins', 'Gandalf'] 252 | """ 253 | 254 | extractor = [] 255 | 256 | # iterate over items in *contains_dupes* 257 | for item in contains_dupes: 258 | # return all duplicate matches found 259 | matches = extract(item, contains_dupes, limit=None, scorer=scorer) 260 | # filter matches based on the threshold 261 | filtered = [x for x in matches if x[1] > threshold] 262 | # if there is only 1 item in *filtered*, no duplicates were found so append to *extracted* 263 | if len(filtered) == 1: 264 | extractor.append(filtered[0][0]) 265 | 266 | else: 267 | # alpha sort 268 | filtered = sorted(filtered, key=lambda x: x[0]) 269 | # length sort 270 | filter_sort = sorted(filtered, key=lambda x: len(x[0]), reverse=True) 271 | # take first item as our 'canonical example' 272 | extractor.append(filter_sort[0][0]) 273 | 274 | # uniquify *extractor* list 275 | keys = {} 276 | for e in extractor: 277 | keys[e] = 1 278 | extractor = keys.keys() 279 | 280 | # check that extractor differs from contain_dupes (e.g. duplicates were found) 281 | # if not, then return the original list 282 | if len(extractor) == len(contains_dupes): 283 | return contains_dupes 284 | else: 285 | return extractor 286 | -------------------------------------------------------------------------------- /fuzzywuzzy/string_processing.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import re 3 | import string 4 | import sys 5 | 6 | PY3 = sys.version_info[0] == 3 7 | if PY3: 8 | string = str 9 | 10 | 11 | class StringProcessor(object): 12 | """ 13 | This class defines method to process strings in the most 14 | efficient way. Ideally all the methods below use unicode strings 15 | for both input and output. 16 | """ 17 | 18 | regex = re.compile(r"(?ui)\W") 19 | 20 | @classmethod 21 | def replace_non_letters_non_numbers_with_whitespace(cls, a_string): 22 | """ 23 | This function replaces any sequence of non letters and non 24 | numbers with a single white space. 25 | """ 26 | return cls.regex.sub(" ", a_string) 27 | 28 | strip = staticmethod(string.strip) 29 | to_lower_case = staticmethod(string.lower) 30 | to_upper_case = staticmethod(string.upper) 31 | -------------------------------------------------------------------------------- /fuzzywuzzy/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import sys 3 | import functools 4 | 5 | from fuzzywuzzy.string_processing import StringProcessor 6 | 7 | 8 | PY3 = sys.version_info[0] == 3 9 | 10 | 11 | def validate_string(s): 12 | """ 13 | Check input has length and that length > 0 14 | 15 | :param s: 16 | :return: True if len(s) > 0 else False 17 | """ 18 | try: 19 | return len(s) > 0 20 | except TypeError: 21 | return False 22 | 23 | 24 | def check_for_equivalence(func): 25 | @functools.wraps(func) 26 | def decorator(*args, **kwargs): 27 | if args[0] == args[1]: 28 | return 100 29 | return func(*args, **kwargs) 30 | return decorator 31 | 32 | 33 | def check_for_none(func): 34 | @functools.wraps(func) 35 | def decorator(*args, **kwargs): 36 | if args[0] is None or args[1] is None: 37 | return 0 38 | return func(*args, **kwargs) 39 | return decorator 40 | 41 | 42 | def check_empty_string(func): 43 | @functools.wraps(func) 44 | def decorator(*args, **kwargs): 45 | if len(args[0]) == 0 or len(args[1]) == 0: 46 | return 0 47 | return func(*args, **kwargs) 48 | return decorator 49 | 50 | 51 | bad_chars = str("").join([chr(i) for i in range(128, 256)]) # ascii dammit! 52 | if PY3: 53 | translation_table = dict((ord(c), None) for c in bad_chars) 54 | unicode = str 55 | 56 | 57 | def asciionly(s): 58 | if PY3: 59 | return s.translate(translation_table) 60 | else: 61 | return s.translate(None, bad_chars) 62 | 63 | 64 | def asciidammit(s): 65 | if type(s) is str: 66 | return asciionly(s) 67 | elif type(s) is unicode: 68 | return asciionly(s.encode('ascii', 'ignore')) 69 | else: 70 | return asciidammit(unicode(s)) 71 | 72 | 73 | def make_type_consistent(s1, s2): 74 | """If both objects aren't either both string or unicode instances force them to unicode""" 75 | if isinstance(s1, str) and isinstance(s2, str): 76 | return s1, s2 77 | 78 | elif isinstance(s1, unicode) and isinstance(s2, unicode): 79 | return s1, s2 80 | 81 | else: 82 | return unicode(s1), unicode(s2) 83 | 84 | 85 | def full_process(s, force_ascii=False): 86 | """Process string by 87 | -- removing all but letters and numbers 88 | -- trim whitespace 89 | -- force to lower case 90 | if force_ascii == True, force convert to ascii""" 91 | 92 | if force_ascii: 93 | s = asciidammit(s) 94 | # Keep only Letters and Numbers (see Unicode docs). 95 | string_out = StringProcessor.replace_non_letters_non_numbers_with_whitespace(s) 96 | # Force into lowercase. 97 | string_out = StringProcessor.to_lower_case(string_out) 98 | # Remove leading and trailing whitespaces. 99 | string_out = StringProcessor.strip(string_out) 100 | return string_out 101 | 102 | 103 | def intr(n): 104 | '''Returns a correctly rounded integer''' 105 | return int(round(n)) 106 | -------------------------------------------------------------------------------- /galaxy/__init__.py: -------------------------------------------------------------------------------- 1 | __path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore 2 | -------------------------------------------------------------------------------- /galaxy/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JTNDev/galaxy-integration-wii/a8fb27925c3003997a06c2384a3255bb1b4f7786/galaxy/api/__init__.py -------------------------------------------------------------------------------- /galaxy/api/consts.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, Flag 2 | 3 | 4 | class Platform(Enum): 5 | """Supported gaming platforms""" 6 | Unknown = "unknown" 7 | Gog = "gog" 8 | Steam = "steam" 9 | Psn = "psn" 10 | XBoxOne = "xboxone" 11 | Generic = "generic" 12 | Origin = "origin" 13 | Uplay = "uplay" 14 | Battlenet = "battlenet" 15 | Epic = "epic" 16 | Bethesda = "bethesda" 17 | ParadoxPlaza = "paradox" 18 | HumbleBundle = "humble" 19 | Kartridge = "kartridge" 20 | ItchIo = "itch" 21 | NintendoSwitch = "nswitch" 22 | NintendoWiiU = "nwiiu" 23 | NintendoWii = "nwii" 24 | NintendoGameCube = "ncube" 25 | RiotGames = "riot" 26 | Wargaming = "wargaming" 27 | NintendoGameBoy = "ngameboy" 28 | Atari = "atari" 29 | Amiga = "amiga" 30 | SuperNintendoEntertainmentSystem = "snes" 31 | Beamdog = "beamdog" 32 | Direct2Drive = "d2d" 33 | Discord = "discord" 34 | DotEmu = "dotemu" 35 | GameHouse = "gamehouse" 36 | GreenManGaming = "gmg" 37 | WePlay = "weplay" 38 | ZxSpectrum = "zx" 39 | ColecoVision = "vision" 40 | NintendoEntertainmentSystem = "nes" 41 | SegaMasterSystem = "sms" 42 | Commodore64 = "c64" 43 | PcEngine = "pce" 44 | SegaGenesis = "segag" 45 | NeoGeo = "neo" 46 | Sega32X = "sega32" 47 | SegaCd = "segacd" 48 | _3Do = "3do" 49 | SegaSaturn = "saturn" 50 | PlayStation = "psx" 51 | PlayStation2 = "ps2" 52 | Nintendo64 = "n64" 53 | AtariJaguar = "jaguar" 54 | SegaDreamcast = "dc" 55 | Xbox = "xboxog" 56 | Amazon = "amazon" 57 | GamersGate = "gg" 58 | Newegg = "egg" 59 | BestBuy = "bb" 60 | GameUk = "gameuk" 61 | Fanatical = "fanatical" 62 | PlayAsia = "playasia" 63 | Stadia = "stadia" 64 | Arc = "arc" 65 | ElderScrollsOnline = "eso" 66 | Glyph = "glyph" 67 | AionLegionsOfWar = "aionl" 68 | Aion = "aion" 69 | BladeAndSoul = "blade" 70 | GuildWars = "gw" 71 | GuildWars2 = "gw2" 72 | Lineage2 = "lin2" 73 | FinalFantasy11 = "ffxi" 74 | FinalFantasy14 = "ffxiv" 75 | TotalWar = "totalwar" 76 | WindowsStore = "winstore" 77 | EliteDangerous = "elites" 78 | StarCitizen = "star" 79 | PlayStationPortable = "psp" 80 | PlayStationVita = "psvita" 81 | NintendoDs = "nds" 82 | Nintendo3Ds = "3ds" 83 | PathOfExile = "pathofexile" 84 | Twitch = "twitch" 85 | Minecraft = "minecraft" 86 | GameSessions = "gamesessions" 87 | Nuuvem = "nuuvem" 88 | FXStore = "fxstore" 89 | IndieGala = "indiegala" 90 | Playfire = "playfire" 91 | Oculus = "oculus" 92 | Test = "test" 93 | Rockstar = "rockstar" 94 | 95 | 96 | class Feature(Enum): 97 | """Possible features that can be implemented by an integration. 98 | It does not have to support all or any specific features from the list. 99 | """ 100 | Unknown = "Unknown" 101 | ImportInstalledGames = "ImportInstalledGames" 102 | ImportOwnedGames = "ImportOwnedGames" 103 | LaunchGame = "LaunchGame" 104 | InstallGame = "InstallGame" 105 | UninstallGame = "UninstallGame" 106 | ImportAchievements = "ImportAchievements" 107 | ImportGameTime = "ImportGameTime" 108 | Chat = "Chat" 109 | ImportUsers = "ImportUsers" 110 | VerifyGame = "VerifyGame" 111 | ImportFriends = "ImportFriends" 112 | ShutdownPlatformClient = "ShutdownPlatformClient" 113 | LaunchPlatformClient = "LaunchPlatformClient" 114 | ImportGameLibrarySettings = "ImportGameLibrarySettings" 115 | ImportOSCompatibility = "ImportOSCompatibility" 116 | ImportUserPresence = "ImportUserPresence" 117 | 118 | 119 | class LicenseType(Enum): 120 | """Possible game license types, understandable for the GOG Galaxy client.""" 121 | Unknown = "Unknown" 122 | SinglePurchase = "SinglePurchase" 123 | FreeToPlay = "FreeToPlay" 124 | OtherUserLicense = "OtherUserLicense" 125 | 126 | 127 | class LocalGameState(Flag): 128 | """Possible states that a local game can be in. 129 | For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: 130 | ``local_game_state=`` 131 | """ 132 | None_ = 0 133 | Installed = 1 134 | Running = 2 135 | 136 | 137 | class OSCompatibility(Flag): 138 | """Possible game OS compatibility. 139 | Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` 140 | """ 141 | Windows = 0b001 142 | MacOS = 0b010 143 | Linux = 0b100 144 | 145 | 146 | class PresenceState(Enum): 147 | """"Possible states of a user.""" 148 | Unknown = "unknown" 149 | Online = "online" 150 | Offline = "offline" 151 | Away = "away" 152 | -------------------------------------------------------------------------------- /galaxy/api/errors.py: -------------------------------------------------------------------------------- 1 | from galaxy.api.jsonrpc import ApplicationError, UnknownError 2 | 3 | assert UnknownError 4 | 5 | class AuthenticationRequired(ApplicationError): 6 | def __init__(self, data=None): 7 | super().__init__(1, "Authentication required", data) 8 | 9 | class BackendNotAvailable(ApplicationError): 10 | def __init__(self, data=None): 11 | super().__init__(2, "Backend not available", data) 12 | 13 | class BackendTimeout(ApplicationError): 14 | def __init__(self, data=None): 15 | super().__init__(3, "Backend timed out", data) 16 | 17 | class BackendError(ApplicationError): 18 | def __init__(self, data=None): 19 | super().__init__(4, "Backend error", data) 20 | 21 | class UnknownBackendResponse(ApplicationError): 22 | def __init__(self, data=None): 23 | super().__init__(4, "Backend responded in uknown way", data) 24 | 25 | class TooManyRequests(ApplicationError): 26 | def __init__(self, data=None): 27 | super().__init__(5, "Too many requests. Try again later", data) 28 | 29 | class InvalidCredentials(ApplicationError): 30 | def __init__(self, data=None): 31 | super().__init__(100, "Invalid credentials", data) 32 | 33 | class NetworkError(ApplicationError): 34 | def __init__(self, data=None): 35 | super().__init__(101, "Network error", data) 36 | 37 | class LoggedInElsewhere(ApplicationError): 38 | def __init__(self, data=None): 39 | super().__init__(102, "Logged in elsewhere", data) 40 | 41 | class ProtocolError(ApplicationError): 42 | def __init__(self, data=None): 43 | super().__init__(103, "Protocol error", data) 44 | 45 | class TemporaryBlocked(ApplicationError): 46 | def __init__(self, data=None): 47 | super().__init__(104, "Temporary blocked", data) 48 | 49 | class Banned(ApplicationError): 50 | def __init__(self, data=None): 51 | super().__init__(105, "Banned", data) 52 | 53 | class AccessDenied(ApplicationError): 54 | def __init__(self, data=None): 55 | super().__init__(106, "Access denied", data) 56 | 57 | class FailedParsingManifest(ApplicationError): 58 | def __init__(self, data=None): 59 | super().__init__(200, "Failed parsing manifest", data) 60 | 61 | class TooManyMessagesSent(ApplicationError): 62 | def __init__(self, data=None): 63 | super().__init__(300, "Too many messages sent", data) 64 | 65 | class IncoherentLastMessage(ApplicationError): 66 | def __init__(self, data=None): 67 | super().__init__(400, "Different last message id on backend", data) 68 | 69 | class MessageNotFound(ApplicationError): 70 | def __init__(self, data=None): 71 | super().__init__(500, "Message not found", data) 72 | 73 | class ImportInProgress(ApplicationError): 74 | def __init__(self, data=None): 75 | super().__init__(600, "Import already in progress", data) 76 | -------------------------------------------------------------------------------- /galaxy/api/jsonrpc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections import namedtuple 3 | from collections.abc import Iterable 4 | import logging 5 | import inspect 6 | import json 7 | 8 | from galaxy.reader import StreamLineReader 9 | from galaxy.task_manager import TaskManager 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class JsonRpcError(Exception): 16 | def __init__(self, code, message, data=None): 17 | self.code = code 18 | self.message = message 19 | self.data = data 20 | super().__init__() 21 | 22 | def __eq__(self, other): 23 | return self.code == other.code and self.message == other.message and self.data == other.data 24 | 25 | def json(self): 26 | obj = { 27 | "code": self.code, 28 | "message": self.message 29 | } 30 | 31 | if self.data is not None: 32 | obj["data"] = self.data 33 | 34 | return obj 35 | 36 | class ParseError(JsonRpcError): 37 | def __init__(self): 38 | super().__init__(-32700, "Parse error") 39 | 40 | class InvalidRequest(JsonRpcError): 41 | def __init__(self): 42 | super().__init__(-32600, "Invalid Request") 43 | 44 | class MethodNotFound(JsonRpcError): 45 | def __init__(self): 46 | super().__init__(-32601, "Method not found") 47 | 48 | class InvalidParams(JsonRpcError): 49 | def __init__(self): 50 | super().__init__(-32602, "Invalid params") 51 | 52 | class Timeout(JsonRpcError): 53 | def __init__(self): 54 | super().__init__(-32000, "Method timed out") 55 | 56 | class Aborted(JsonRpcError): 57 | def __init__(self): 58 | super().__init__(-32001, "Method aborted") 59 | 60 | class ApplicationError(JsonRpcError): 61 | def __init__(self, code, message, data): 62 | if code >= -32768 and code <= -32000: 63 | raise ValueError("The error code in reserved range") 64 | super().__init__(code, message, data) 65 | 66 | class UnknownError(ApplicationError): 67 | def __init__(self, data=None): 68 | super().__init__(0, "Unknown error", data) 69 | 70 | Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) 71 | Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) 72 | Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) 73 | 74 | 75 | def anonymise_sensitive_params(params, sensitive_params): 76 | anomized_data = "****" 77 | 78 | if isinstance(sensitive_params, bool): 79 | if sensitive_params: 80 | return {k:anomized_data for k,v in params.items()} 81 | 82 | if isinstance(sensitive_params, Iterable): 83 | return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} 84 | 85 | return params 86 | 87 | class Connection(): 88 | def __init__(self, reader, writer, encoder=json.JSONEncoder()): 89 | self._active = True 90 | self._reader = StreamLineReader(reader) 91 | self._writer = writer 92 | self._encoder = encoder 93 | self._methods = {} 94 | self._notifications = {} 95 | self._task_manager = TaskManager("jsonrpc server") 96 | self._last_request_id = 0 97 | self._requests_futures = {} 98 | 99 | def register_method(self, name, callback, immediate, sensitive_params=False): 100 | """ 101 | Register method 102 | 103 | :param name: 104 | :param callback: 105 | :param internal: if True the callback will be processed immediately (synchronously) 106 | :param sensitive_params: list of parameters that are anonymized before logging; \ 107 | if False - no params are considered sensitive, if True - all params are considered sensitive 108 | """ 109 | self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) 110 | 111 | def register_notification(self, name, callback, immediate, sensitive_params=False): 112 | """ 113 | Register notification 114 | 115 | :param name: 116 | :param callback: 117 | :param internal: if True the callback will be processed immediately (synchronously) 118 | :param sensitive_params: list of parameters that are anonymized before logging; \ 119 | if False - no params are considered sensitive, if True - all params are considered sensitive 120 | """ 121 | self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) 122 | 123 | async def send_request(self, method, params, sensitive_params): 124 | """ 125 | Send request 126 | 127 | :param method: 128 | :param params: 129 | :param sensitive_params: list of parameters that are anonymized before logging; \ 130 | if False - no params are considered sensitive, if True - all params are considered sensitive 131 | """ 132 | self._last_request_id += 1 133 | request_id = str(self._last_request_id) 134 | 135 | loop = asyncio.get_running_loop() 136 | future = loop.create_future() 137 | self._requests_futures[self._last_request_id] = (future, sensitive_params) 138 | 139 | logger.info( 140 | "Sending request: id=%s, method=%s, params=%s", 141 | request_id, method, anonymise_sensitive_params(params, sensitive_params) 142 | ) 143 | 144 | self._send_request(request_id, method, params) 145 | return await future 146 | 147 | def send_notification(self, method, params, sensitive_params=False): 148 | """ 149 | Send notification 150 | 151 | :param method: 152 | :param params: 153 | :param sensitive_params: list of parameters that are anonymized before logging; \ 154 | if False - no params are considered sensitive, if True - all params are considered sensitive 155 | """ 156 | 157 | logger.info( 158 | "Sending notification: method=%s, params=%s", 159 | method, anonymise_sensitive_params(params, sensitive_params) 160 | ) 161 | 162 | self._send_notification(method, params) 163 | 164 | async def run(self): 165 | while self._active: 166 | try: 167 | data = await self._reader.readline() 168 | if not data: 169 | self._eof() 170 | continue 171 | except: 172 | self._eof() 173 | continue 174 | data = data.strip() 175 | logger.debug("Received %d bytes of data", len(data)) 176 | self._handle_input(data) 177 | await asyncio.sleep(0) # To not starve task queue 178 | 179 | def close(self): 180 | if self._active: 181 | logger.info("Closing JSON-RPC server - not more messages will be read") 182 | self._active = False 183 | 184 | async def wait_closed(self): 185 | await self._task_manager.wait() 186 | 187 | def _eof(self): 188 | logger.info("Received EOF") 189 | self.close() 190 | 191 | def _handle_input(self, data): 192 | try: 193 | message = self._parse_message(data) 194 | except JsonRpcError as error: 195 | self._send_error(None, error) 196 | return 197 | 198 | if isinstance(message, Request): 199 | if message.id is not None: 200 | self._handle_request(message) 201 | else: 202 | self._handle_notification(message) 203 | elif isinstance(message, Response): 204 | self._handle_response(message) 205 | 206 | def _handle_response(self, response): 207 | request_future = self._requests_futures.get(int(response.id)) 208 | if request_future is None: 209 | response_type = "response" if response.result is not None else "error" 210 | logger.warning("Received %s for unknown request: %s", response_type, response.id) 211 | return 212 | 213 | future, sensitive_params = request_future 214 | 215 | if response.error: 216 | error = JsonRpcError( 217 | response.error.setdefault("code", 0), 218 | response.error.setdefault("message", ""), 219 | response.error.setdefault("data", None) 220 | ) 221 | self._log_error(response, error, sensitive_params) 222 | future.set_exception(error) 223 | return 224 | 225 | self._log_response(response, sensitive_params) 226 | future.set_result(response.result) 227 | 228 | def _handle_notification(self, request): 229 | method = self._notifications.get(request.method) 230 | if not method: 231 | logger.error("Received unknown notification: %s", request.method) 232 | return 233 | 234 | callback, signature, immediate, sensitive_params = method 235 | self._log_request(request, sensitive_params) 236 | 237 | try: 238 | bound_args = signature.bind(**request.params) 239 | except TypeError: 240 | self._send_error(request.id, InvalidParams()) 241 | 242 | if immediate: 243 | callback(*bound_args.args, **bound_args.kwargs) 244 | else: 245 | try: 246 | self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) 247 | except Exception: 248 | logger.exception("Unexpected exception raised in notification handler") 249 | 250 | def _handle_request(self, request): 251 | method = self._methods.get(request.method) 252 | if not method: 253 | logger.error("Received unknown request: %s", request.method) 254 | self._send_error(request.id, MethodNotFound()) 255 | return 256 | 257 | callback, signature, immediate, sensitive_params = method 258 | self._log_request(request, sensitive_params) 259 | 260 | try: 261 | bound_args = signature.bind(**request.params) 262 | except TypeError: 263 | self._send_error(request.id, InvalidParams()) 264 | 265 | if immediate: 266 | response = callback(*bound_args.args, **bound_args.kwargs) 267 | self._send_response(request.id, response) 268 | else: 269 | async def handle(): 270 | try: 271 | result = await callback(*bound_args.args, **bound_args.kwargs) 272 | self._send_response(request.id, result) 273 | except NotImplementedError: 274 | self._send_error(request.id, MethodNotFound()) 275 | except JsonRpcError as error: 276 | self._send_error(request.id, error) 277 | except asyncio.CancelledError: 278 | self._send_error(request.id, Aborted()) 279 | except Exception as e: #pylint: disable=broad-except 280 | logger.exception("Unexpected exception raised in plugin handler") 281 | self._send_error(request.id, UnknownError(str(e))) 282 | 283 | self._task_manager.create_task(handle(), request.method) 284 | 285 | @staticmethod 286 | def _parse_message(data): 287 | try: 288 | jsonrpc_message = json.loads(data, encoding="utf-8") 289 | if jsonrpc_message.get("jsonrpc") != "2.0": 290 | raise InvalidRequest() 291 | del jsonrpc_message["jsonrpc"] 292 | if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): 293 | return Response(**jsonrpc_message) 294 | else: 295 | return Request(**jsonrpc_message) 296 | 297 | except json.JSONDecodeError: 298 | raise ParseError() 299 | except TypeError: 300 | raise InvalidRequest() 301 | 302 | def _send(self, data, sensitive=True): 303 | try: 304 | line = self._encoder.encode(data) 305 | data = (line + "\n").encode("utf-8") 306 | if sensitive: 307 | logger.debug("Sending %d bytes of data", len(data)) 308 | else: 309 | logging.debug("Sending data: %s", line) 310 | self._writer.write(data) 311 | except TypeError as error: 312 | logger.error(str(error)) 313 | 314 | def _send_response(self, request_id, result): 315 | response = { 316 | "jsonrpc": "2.0", 317 | "id": request_id, 318 | "result": result 319 | } 320 | self._send(response, sensitive=False) 321 | 322 | def _send_error(self, request_id, error): 323 | response = { 324 | "jsonrpc": "2.0", 325 | "id": request_id, 326 | "error": error.json() 327 | } 328 | 329 | self._send(response, sensitive=False) 330 | 331 | def _send_request(self, request_id, method, params): 332 | request = { 333 | "jsonrpc": "2.0", 334 | "method": method, 335 | "id": request_id, 336 | "params": params 337 | } 338 | self._send(request, sensitive=True) 339 | 340 | def _send_notification(self, method, params): 341 | notification = { 342 | "jsonrpc": "2.0", 343 | "method": method, 344 | "params": params 345 | } 346 | self._send(notification, sensitive=True) 347 | 348 | @staticmethod 349 | def _log_request(request, sensitive_params): 350 | params = anonymise_sensitive_params(request.params, sensitive_params) 351 | if request.id is not None: 352 | logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) 353 | else: 354 | logger.info("Handling notification: method=%s, params=%s", request.method, params) 355 | 356 | @staticmethod 357 | def _log_response(response, sensitive_params): 358 | result = anonymise_sensitive_params(response.result, sensitive_params) 359 | logger.info("Handling response: id=%s, result=%s", response.id, result) 360 | 361 | @staticmethod 362 | def _log_error(response, error, sensitive_params): 363 | data = anonymise_sensitive_params(error.data, sensitive_params) 364 | logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", 365 | response.id, error.code, error.message, data 366 | ) 367 | -------------------------------------------------------------------------------- /galaxy/api/plugin.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import dataclasses 3 | import json 4 | import logging 5 | import sys 6 | from enum import Enum 7 | from typing import Any, Dict, List, Optional, Set, Union 8 | 9 | from galaxy.api.consts import Feature, OSCompatibility 10 | from galaxy.api.errors import ImportInProgress, UnknownError 11 | from galaxy.api.jsonrpc import ApplicationError, Connection 12 | from galaxy.api.types import ( 13 | Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence 14 | ) 15 | from galaxy.task_manager import TaskManager 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class JSONEncoder(json.JSONEncoder): 22 | def default(self, o): # pylint: disable=method-hidden 23 | if dataclasses.is_dataclass(o): 24 | # filter None values 25 | def dict_factory(elements): 26 | return {k: v for k, v in elements if v is not None} 27 | 28 | return dataclasses.asdict(o, dict_factory=dict_factory) 29 | if isinstance(o, Enum): 30 | return o.value 31 | return super().default(o) 32 | 33 | 34 | class Importer: 35 | def __init__( 36 | self, 37 | task_manger, 38 | name, 39 | get, 40 | prepare_context, 41 | notification_success, 42 | notification_failure, 43 | notification_finished, 44 | complete 45 | ): 46 | self._task_manager = task_manger 47 | self._name = name 48 | self._get = get 49 | self._prepare_context = prepare_context 50 | self._notification_success = notification_success 51 | self._notification_failure = notification_failure 52 | self._notification_finished = notification_finished 53 | self._complete = complete 54 | 55 | self._import_in_progress = False 56 | 57 | async def start(self, ids): 58 | if self._import_in_progress: 59 | raise ImportInProgress() 60 | 61 | async def import_element(id_, context_): 62 | try: 63 | element = await self._get(id_, context_) 64 | self._notification_success(id_, element) 65 | except ApplicationError as error: 66 | self._notification_failure(id_, error) 67 | except asyncio.CancelledError: 68 | pass 69 | except Exception: 70 | logger.exception("Unexpected exception raised in %s importer", self._name) 71 | self._notification_failure(id_, UnknownError()) 72 | 73 | async def import_elements(ids_, context_): 74 | try: 75 | imports = [import_element(id_, context_) for id_ in ids_] 76 | await asyncio.gather(*imports) 77 | self._notification_finished() 78 | self._complete() 79 | except asyncio.CancelledError: 80 | logger.debug("Importing %s cancelled", self._name) 81 | finally: 82 | self._import_in_progress = False 83 | 84 | self._import_in_progress = True 85 | try: 86 | context = await self._prepare_context(ids) 87 | self._task_manager.create_task( 88 | import_elements(ids, context), 89 | "{} import".format(self._name), 90 | handle_exceptions=False 91 | ) 92 | except: 93 | self._import_in_progress = False 94 | raise 95 | 96 | 97 | class Plugin: 98 | """Use and override methods of this class to create a new platform integration.""" 99 | 100 | def __init__(self, platform, version, reader, writer, handshake_token): 101 | logger.info("Creating plugin for platform %s, version %s", platform.value, version) 102 | self._platform = platform 103 | self._version = version 104 | 105 | self._features: Set[Feature] = set() 106 | self._active = True 107 | 108 | self._reader, self._writer = reader, writer 109 | self._handshake_token = handshake_token 110 | 111 | encoder = JSONEncoder() 112 | self._connection = Connection(self._reader, self._writer, encoder) 113 | 114 | self._persistent_cache = dict() 115 | 116 | self._internal_task_manager = TaskManager("plugin internal") 117 | self._external_task_manager = TaskManager("plugin external") 118 | 119 | self._achievements_importer = Importer( 120 | self._external_task_manager, 121 | "achievements", 122 | self.get_unlocked_achievements, 123 | self.prepare_achievements_context, 124 | self._game_achievements_import_success, 125 | self._game_achievements_import_failure, 126 | self._achievements_import_finished, 127 | self.achievements_import_complete 128 | ) 129 | self._game_time_importer = Importer( 130 | self._external_task_manager, 131 | "game times", 132 | self.get_game_time, 133 | self.prepare_game_times_context, 134 | self._game_time_import_success, 135 | self._game_time_import_failure, 136 | self._game_times_import_finished, 137 | self.game_times_import_complete 138 | ) 139 | self._game_library_settings_importer = Importer( 140 | self._external_task_manager, 141 | "game library settings", 142 | self.get_game_library_settings, 143 | self.prepare_game_library_settings_context, 144 | self._game_library_settings_import_success, 145 | self._game_library_settings_import_failure, 146 | self._game_library_settings_import_finished, 147 | self.game_library_settings_import_complete 148 | ) 149 | self._os_compatibility_importer = Importer( 150 | self._external_task_manager, 151 | "os compatibility", 152 | self.get_os_compatibility, 153 | self.prepare_os_compatibility_context, 154 | self._os_compatibility_import_success, 155 | self._os_compatibility_import_failure, 156 | self._os_compatibility_import_finished, 157 | self.os_compatibility_import_complete 158 | ) 159 | self._user_presence_importer = Importer( 160 | self._external_task_manager, 161 | "users presence", 162 | self.get_user_presence, 163 | self.prepare_user_presence_context, 164 | self._user_presence_import_success, 165 | self._user_presence_import_failure, 166 | self._user_presence_import_finished, 167 | self.user_presence_import_complete 168 | ) 169 | 170 | # internal 171 | self._register_method("shutdown", self._shutdown, internal=True) 172 | self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) 173 | self._register_method( 174 | "initialize_cache", 175 | self._initialize_cache, 176 | internal=True, 177 | immediate=True, 178 | sensitive_params="data" 179 | ) 180 | self._register_method("ping", self._ping, internal=True, immediate=True) 181 | 182 | # implemented by developer 183 | self._register_method( 184 | "init_authentication", 185 | self.authenticate, 186 | sensitive_params=["stored_credentials"] 187 | ) 188 | self._register_method( 189 | "pass_login_credentials", 190 | self.pass_login_credentials, 191 | sensitive_params=["cookies", "credentials"] 192 | ) 193 | self._register_method( 194 | "import_owned_games", 195 | self.get_owned_games, 196 | result_name="owned_games" 197 | ) 198 | self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) 199 | 200 | self._register_method("start_achievements_import", self._start_achievements_import) 201 | self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) 202 | 203 | self._register_method("import_local_games", self.get_local_games, result_name="local_games") 204 | self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) 205 | 206 | self._register_notification("launch_game", self.launch_game) 207 | self._detect_feature(Feature.LaunchGame, ["launch_game"]) 208 | 209 | self._register_notification("install_game", self.install_game) 210 | self._detect_feature(Feature.InstallGame, ["install_game"]) 211 | 212 | self._register_notification("uninstall_game", self.uninstall_game) 213 | self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) 214 | 215 | self._register_notification("shutdown_platform_client", self.shutdown_platform_client) 216 | self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) 217 | 218 | self._register_notification("launch_platform_client", self.launch_platform_client) 219 | self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) 220 | 221 | self._register_method("import_friends", self.get_friends, result_name="friend_info_list") 222 | self._detect_feature(Feature.ImportFriends, ["get_friends"]) 223 | 224 | self._register_method("start_game_times_import", self._start_game_times_import) 225 | self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) 226 | 227 | self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) 228 | self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) 229 | 230 | self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) 231 | self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) 232 | 233 | self._register_method("start_user_presence_import", self._start_user_presence_import) 234 | self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) 235 | 236 | async def __aenter__(self): 237 | return self 238 | 239 | async def __aexit__(self, exc_type, exc, tb): 240 | self.close() 241 | await self.wait_closed() 242 | 243 | @property 244 | def features(self) -> List[Feature]: 245 | return list(self._features) 246 | 247 | @property 248 | def persistent_cache(self) -> Dict[str, str]: 249 | """The cache is only available after the :meth:`~.handshake_complete()` is called. 250 | """ 251 | return self._persistent_cache 252 | 253 | def _implements(self, methods: List[str]) -> bool: 254 | for method in methods: 255 | if method not in self.__class__.__dict__: 256 | return False 257 | return True 258 | 259 | def _detect_feature(self, feature: Feature, methods: List[str]): 260 | if self._implements(methods): 261 | self._features.add(feature) 262 | 263 | def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): 264 | def wrap_result(result): 265 | if result_name: 266 | result = { 267 | result_name: result 268 | } 269 | return result 270 | 271 | if immediate: 272 | def method(*args, **kwargs): 273 | result = handler(*args, **kwargs) 274 | return wrap_result(result) 275 | 276 | self._connection.register_method(name, method, True, sensitive_params) 277 | else: 278 | async def method(*args, **kwargs): 279 | if not internal: 280 | handler_ = self._wrap_external_method(handler, name) 281 | else: 282 | handler_ = handler 283 | result = await handler_(*args, **kwargs) 284 | return wrap_result(result) 285 | 286 | self._connection.register_method(name, method, False, sensitive_params) 287 | 288 | def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): 289 | if not internal and not immediate: 290 | handler = self._wrap_external_method(handler, name) 291 | self._connection.register_notification(name, handler, immediate, sensitive_params) 292 | 293 | def _wrap_external_method(self, handler, name: str): 294 | async def wrapper(*args, **kwargs): 295 | return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) 296 | 297 | return wrapper 298 | 299 | async def run(self): 300 | """Plugin's main coroutine.""" 301 | await self._connection.run() 302 | logger.debug("Plugin run loop finished") 303 | 304 | def close(self) -> None: 305 | if not self._active: 306 | return 307 | 308 | logger.info("Closing plugin") 309 | self._connection.close() 310 | self._external_task_manager.cancel() 311 | 312 | async def shutdown(): 313 | try: 314 | await asyncio.wait_for(self.shutdown(), 30) 315 | except asyncio.TimeoutError: 316 | logging.warning("Plugin shutdown timed out") 317 | 318 | self._internal_task_manager.create_task(shutdown(), "shutdown") 319 | self._active = False 320 | 321 | async def wait_closed(self) -> None: 322 | logger.debug("Waiting for plugin to close") 323 | await self._external_task_manager.wait() 324 | await self._internal_task_manager.wait() 325 | await self._connection.wait_closed() 326 | logger.debug("Plugin closed") 327 | 328 | def create_task(self, coro, description): 329 | """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" 330 | return self._external_task_manager.create_task(coro, description) 331 | 332 | async def _pass_control(self): 333 | while self._active: 334 | try: 335 | self.tick() 336 | except Exception: 337 | logger.exception("Unexpected exception raised in plugin tick") 338 | await asyncio.sleep(1) 339 | 340 | async def _shutdown(self): 341 | logger.info("Shutting down") 342 | self.close() 343 | await self._external_task_manager.wait() 344 | await self._internal_task_manager.wait() 345 | 346 | def _get_capabilities(self): 347 | return { 348 | "platform_name": self._platform, 349 | "features": self.features, 350 | "token": self._handshake_token 351 | } 352 | 353 | def _initialize_cache(self, data: Dict): 354 | self._persistent_cache = data 355 | try: 356 | self.handshake_complete() 357 | except Exception: 358 | logger.exception("Unhandled exception during `handshake_complete` step") 359 | self._internal_task_manager.create_task(self._pass_control(), "tick") 360 | 361 | @staticmethod 362 | def _ping(): 363 | pass 364 | 365 | # notifications 366 | def store_credentials(self, credentials: Dict[str, Any]) -> None: 367 | """Notify the client to store authentication credentials. 368 | Credentials are passed on the next authenticate call. 369 | 370 | :param credentials: credentials that client will store; they are stored locally on a user pc 371 | 372 | Example use case of store_credentials: 373 | 374 | .. code-block:: python 375 | :linenos: 376 | 377 | async def pass_login_credentials(self, step, credentials, cookies): 378 | if self.got_everything(credentials,cookies): 379 | user_data = await self.parse_credentials(credentials,cookies) 380 | else: 381 | next_params = self.get_next_params(credentials,cookies) 382 | next_cookies = self.get_next_cookies(credentials,cookies) 383 | return NextStep("web_session", next_params, cookies=next_cookies) 384 | self.store_credentials(user_data['credentials']) 385 | return Authentication(user_data['userId'], user_data['username']) 386 | 387 | """ 388 | # temporary solution for persistent_cache vs credentials issue 389 | self.persistent_cache["credentials"] = credentials # type: ignore 390 | 391 | self._connection.send_notification("store_credentials", credentials, sensitive_params=True) 392 | 393 | def add_game(self, game: Game) -> None: 394 | """Notify the client to add game to the list of owned games 395 | of the currently authenticated user. 396 | 397 | :param game: Game to add to the list of owned games 398 | 399 | Example use case of add_game: 400 | 401 | .. code-block:: python 402 | :linenos: 403 | 404 | async def check_for_new_games(self): 405 | games = await self.get_owned_games() 406 | for game in games: 407 | if game not in self.owned_games_cache: 408 | self.owned_games_cache.append(game) 409 | self.add_game(game) 410 | 411 | """ 412 | params = {"owned_game": game} 413 | self._connection.send_notification("owned_game_added", params) 414 | 415 | def remove_game(self, game_id: str) -> None: 416 | """Notify the client to remove game from the list of owned games 417 | of the currently authenticated user. 418 | 419 | :param game_id: the id of the game to remove from the list of owned games 420 | 421 | Example use case of remove_game: 422 | 423 | .. code-block:: python 424 | :linenos: 425 | 426 | async def check_for_removed_games(self): 427 | games = await self.get_owned_games() 428 | for game in self.owned_games_cache: 429 | if game not in games: 430 | self.owned_games_cache.remove(game) 431 | self.remove_game(game.game_id) 432 | 433 | """ 434 | params = {"game_id": game_id} 435 | self._connection.send_notification("owned_game_removed", params) 436 | 437 | def update_game(self, game: Game) -> None: 438 | """Notify the client to update the status of a game 439 | owned by the currently authenticated user. 440 | 441 | :param game: Game to update 442 | """ 443 | params = {"owned_game": game} 444 | self._connection.send_notification("owned_game_updated", params) 445 | 446 | def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: 447 | """Notify the client to unlock an achievement for a specific game. 448 | 449 | :param game_id: the id of the game for which to unlock an achievement. 450 | :param achievement: achievement to unlock. 451 | """ 452 | params = { 453 | "game_id": game_id, 454 | "achievement": achievement 455 | } 456 | self._connection.send_notification("achievement_unlocked", params) 457 | 458 | def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: 459 | params = { 460 | "game_id": game_id, 461 | "unlocked_achievements": achievements 462 | } 463 | self._connection.send_notification("game_achievements_import_success", params) 464 | 465 | def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: 466 | params = { 467 | "game_id": game_id, 468 | "error": error.json() 469 | } 470 | self._connection.send_notification("game_achievements_import_failure", params) 471 | 472 | def _achievements_import_finished(self) -> None: 473 | self._connection.send_notification("achievements_import_finished", None) 474 | 475 | def update_local_game_status(self, local_game: LocalGame) -> None: 476 | """Notify the client to update the status of a local game. 477 | 478 | :param local_game: the LocalGame to update 479 | 480 | Example use case triggered by the :meth:`.tick` method: 481 | 482 | .. code-block:: python 483 | :linenos: 484 | :emphasize-lines: 5 485 | 486 | async def _check_statuses(self): 487 | for game in await self._get_local_games(): 488 | if game.status == self._cached_game_statuses.get(game.id): 489 | continue 490 | self.update_local_game_status(LocalGame(game.id, game.status)) 491 | self._cached_games_statuses[game.id] = game.status 492 | await asyncio.sleep(5) # interval 493 | 494 | def tick(self): 495 | if self._check_statuses_task is None or self._check_statuses_task.done(): 496 | self._check_statuses_task = asyncio.create_task(self._check_statuses()) 497 | """ 498 | params = {"local_game": local_game} 499 | self._connection.send_notification("local_game_status_changed", params) 500 | 501 | def add_friend(self, user: UserInfo) -> None: 502 | """Notify the client to add a user to friends list of the currently authenticated user. 503 | 504 | :param user: UserInfo of a user that the client will add to friends list 505 | """ 506 | params = {"friend_info": user} 507 | self._connection.send_notification("friend_added", params) 508 | 509 | def remove_friend(self, user_id: str) -> None: 510 | """Notify the client to remove a user from friends list of the currently authenticated user. 511 | 512 | :param user_id: id of the user to remove from friends list 513 | """ 514 | params = {"user_id": user_id} 515 | self._connection.send_notification("friend_removed", params) 516 | 517 | def update_friend_info(self, user: UserInfo) -> None: 518 | """Notify the client about the updated friend information. 519 | 520 | :param user: UserInfo of a friend whose info was updated 521 | """ 522 | self._connection.send_notification("friend_updated", params={"friend_info": user}) 523 | 524 | def update_game_time(self, game_time: GameTime) -> None: 525 | """Notify the client to update game time for a game. 526 | 527 | :param game_time: game time to update 528 | """ 529 | params = {"game_time": game_time} 530 | self._connection.send_notification("game_time_updated", params) 531 | 532 | def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: 533 | """Notify the client about the updated user presence information. 534 | 535 | :param user_id: the id of the user whose presence information is updated 536 | :param user_presence: presence information of the specified user 537 | """ 538 | self._connection.send_notification( 539 | "user_presence_updated", 540 | { 541 | "user_id": user_id, 542 | "presence": user_presence 543 | } 544 | ) 545 | 546 | def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: 547 | params = {"game_time": game_time} 548 | self._connection.send_notification("game_time_import_success", params) 549 | 550 | def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: 551 | params = { 552 | "game_id": game_id, 553 | "error": error.json() 554 | } 555 | self._connection.send_notification("game_time_import_failure", params) 556 | 557 | def _game_times_import_finished(self) -> None: 558 | self._connection.send_notification("game_times_import_finished", None) 559 | 560 | def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: 561 | params = {"game_library_settings": game_library_settings} 562 | self._connection.send_notification("game_library_settings_import_success", params) 563 | 564 | def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: 565 | params = { 566 | "game_id": game_id, 567 | "error": error.json() 568 | } 569 | self._connection.send_notification("game_library_settings_import_failure", params) 570 | 571 | def _game_library_settings_import_finished(self) -> None: 572 | self._connection.send_notification("game_library_settings_import_finished", None) 573 | 574 | def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: 575 | self._connection.send_notification( 576 | "os_compatibility_import_success", 577 | { 578 | "game_id": game_id, 579 | "os_compatibility": os_compatibility 580 | } 581 | ) 582 | 583 | def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: 584 | self._connection.send_notification( 585 | "os_compatibility_import_failure", 586 | { 587 | "game_id": game_id, 588 | "error": error.json() 589 | } 590 | ) 591 | 592 | def _os_compatibility_import_finished(self) -> None: 593 | self._connection.send_notification("os_compatibility_import_finished", None) 594 | 595 | def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: 596 | self._connection.send_notification( 597 | "user_presence_import_success", 598 | { 599 | "user_id": user_id, 600 | "presence": user_presence 601 | } 602 | ) 603 | 604 | def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: 605 | self._connection.send_notification( 606 | "user_presence_import_failure", 607 | { 608 | "user_id": user_id, 609 | "error": error.json() 610 | } 611 | ) 612 | 613 | def _user_presence_import_finished(self) -> None: 614 | self._connection.send_notification("user_presence_import_finished", None) 615 | 616 | def lost_authentication(self) -> None: 617 | """Notify the client that integration has lost authentication for the 618 | current user and is unable to perform actions which would require it. 619 | """ 620 | self._connection.send_notification("authentication_lost", None) 621 | 622 | def push_cache(self) -> None: 623 | """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. 624 | """ 625 | self._connection.send_notification( 626 | "push_cache", 627 | params={"data": self._persistent_cache}, 628 | sensitive_params="data" 629 | ) 630 | 631 | async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: 632 | return await self._connection.send_request("refresh_credentials", params, sensitive_params) 633 | 634 | # handlers 635 | def handshake_complete(self) -> None: 636 | """This method is called right after the handshake with the GOG Galaxy Client is complete and 637 | before any other operations are called by the GOG Galaxy Client. 638 | Persistent cache is available when this method is called. 639 | Override it if you need to do additional plugin initializations. 640 | This method is called internally.""" 641 | 642 | def tick(self) -> None: 643 | """This method is called periodically. 644 | Override it to implement periodical non-blocking tasks. 645 | This method is called internally. 646 | 647 | Example of possible override of the method: 648 | 649 | .. code-block:: python 650 | :linenos: 651 | 652 | def tick(self): 653 | if not self.checking_for_new_games: 654 | asyncio.create_task(self.check_for_new_games()) 655 | if not self.checking_for_removed_games: 656 | asyncio.create_task(self.check_for_removed_games()) 657 | if not self.updating_game_statuses: 658 | asyncio.create_task(self.update_game_statuses()) 659 | 660 | """ 661 | 662 | async def shutdown(self) -> None: 663 | """This method is called on integration shutdown. 664 | Override it to implement tear down. 665 | This method is called by the GOG Galaxy Client.""" 666 | 667 | # methods 668 | async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: 669 | """Override this method to handle user authentication. 670 | This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished 671 | or :class:`~galaxy.api.types.NextStep` if it requires going to another url. 672 | This method is called by the GOG Galaxy Client. 673 | 674 | :param stored_credentials: If the client received any credentials to store locally 675 | in the previous session they will be passed here as a parameter. 676 | 677 | 678 | Example of possible override of the method: 679 | 680 | .. code-block:: python 681 | :linenos: 682 | 683 | async def authenticate(self, stored_credentials=None): 684 | if not stored_credentials: 685 | return NextStep("web_session", PARAMS, cookies=COOKIES) 686 | else: 687 | try: 688 | user_data = self._authenticate(stored_credentials) 689 | except AccessDenied: 690 | raise InvalidCredentials() 691 | return Authentication(user_data['userId'], user_data['username']) 692 | 693 | """ 694 | raise NotImplementedError() 695 | 696 | async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ 697 | -> Union[NextStep, Authentication]: 698 | """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` 699 | or :meth:`.pass_login_credentials`. 700 | This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. 701 | This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished 702 | or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. 703 | This method is called by the GOG Galaxy Client. 704 | 705 | :param step: deprecated. 706 | :param credentials: end_uri previous NextStep finished on. 707 | :param cookies: cookies extracted from the end_uri site. 708 | 709 | Example of possible override of the method: 710 | 711 | .. code-block:: python 712 | :linenos: 713 | 714 | async def pass_login_credentials(self, step, credentials, cookies): 715 | if self.got_everything(credentials,cookies): 716 | user_data = await self.parse_credentials(credentials,cookies) 717 | else: 718 | next_params = self.get_next_params(credentials,cookies) 719 | next_cookies = self.get_next_cookies(credentials,cookies) 720 | return NextStep("web_session", next_params, cookies=next_cookies) 721 | self.store_credentials(user_data['credentials']) 722 | return Authentication(user_data['userId'], user_data['username']) 723 | 724 | """ 725 | raise NotImplementedError() 726 | 727 | async def get_owned_games(self) -> List[Game]: 728 | """Override this method to return owned games for currently logged in user. 729 | This method is called by the GOG Galaxy Client. 730 | 731 | Example of possible override of the method: 732 | 733 | .. code-block:: python 734 | :linenos: 735 | 736 | async def get_owned_games(self): 737 | if not self.authenticated(): 738 | raise AuthenticationRequired() 739 | 740 | games = self.retrieve_owned_games() 741 | return games 742 | 743 | """ 744 | raise NotImplementedError() 745 | 746 | async def _start_achievements_import(self, game_ids: List[str]) -> None: 747 | await self._achievements_importer.start(game_ids) 748 | 749 | async def prepare_achievements_context(self, game_ids: List[str]) -> Any: 750 | """Override this method to prepare context for get_unlocked_achievements. 751 | This allows for optimizations like batch requests to platform API. 752 | Default implementation returns None. 753 | 754 | :param game_ids: the ids of the games for which achievements are imported 755 | :return: context 756 | """ 757 | return None 758 | 759 | async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: 760 | """Override this method to return list of unlocked achievements 761 | for the game identified by the provided game_id. 762 | This method is called by import task initialized by GOG Galaxy Client. 763 | 764 | :param game_id: the id of the game for which the achievements are returned 765 | :param context: the value returned from :meth:`prepare_achievements_context` 766 | :return: list of Achievement objects 767 | """ 768 | raise NotImplementedError() 769 | 770 | def achievements_import_complete(self): 771 | """Override this method to handle operations after achievements import is finished 772 | (like updating cache). 773 | """ 774 | 775 | async def get_local_games(self) -> List[LocalGame]: 776 | """Override this method to return the list of 777 | games present locally on the users pc. 778 | This method is called by the GOG Galaxy Client. 779 | 780 | Example of possible override of the method: 781 | 782 | .. code-block:: python 783 | :linenos: 784 | 785 | async def get_local_games(self): 786 | local_games = [] 787 | for game in self.games_present_on_user_pc: 788 | local_game = LocalGame() 789 | local_game.game_id = game.id 790 | local_game.local_game_state = game.get_installation_status() 791 | local_games.append(local_game) 792 | return local_games 793 | 794 | """ 795 | raise NotImplementedError() 796 | 797 | async def launch_game(self, game_id: str) -> None: 798 | """Override this method to launch the game 799 | identified by the provided game_id. 800 | This method is called by the GOG Galaxy Client. 801 | 802 | :param str game_id: the id of the game to launch 803 | 804 | Example of possible override of the method: 805 | 806 | .. code-block:: python 807 | :linenos: 808 | 809 | async def launch_game(self, game_id): 810 | await self.open_uri(f"start client://launchgame/{game_id}") 811 | 812 | """ 813 | raise NotImplementedError() 814 | 815 | async def install_game(self, game_id: str) -> None: 816 | """Override this method to install the game 817 | identified by the provided game_id. 818 | This method is called by the GOG Galaxy Client. 819 | 820 | :param str game_id: the id of the game to install 821 | 822 | Example of possible override of the method: 823 | 824 | .. code-block:: python 825 | :linenos: 826 | 827 | async def install_game(self, game_id): 828 | await self.open_uri(f"start client://installgame/{game_id}") 829 | 830 | """ 831 | raise NotImplementedError() 832 | 833 | async def uninstall_game(self, game_id: str) -> None: 834 | """Override this method to uninstall the game 835 | identified by the provided game_id. 836 | This method is called by the GOG Galaxy Client. 837 | 838 | :param str game_id: the id of the game to uninstall 839 | 840 | Example of possible override of the method: 841 | 842 | .. code-block:: python 843 | :linenos: 844 | 845 | async def uninstall_game(self, game_id): 846 | await self.open_uri(f"start client://uninstallgame/{game_id}") 847 | 848 | """ 849 | raise NotImplementedError() 850 | 851 | async def shutdown_platform_client(self) -> None: 852 | """Override this method to gracefully terminate platform client. 853 | This method is called by the GOG Galaxy Client.""" 854 | raise NotImplementedError() 855 | 856 | async def launch_platform_client(self) -> None: 857 | """Override this method to launch platform client. Preferably minimized to tray. 858 | This method is called by the GOG Galaxy Client.""" 859 | raise NotImplementedError() 860 | 861 | async def get_friends(self) -> List[UserInfo]: 862 | """Override this method to return the friends list 863 | of the currently authenticated user. 864 | This method is called by the GOG Galaxy Client. 865 | 866 | Example of possible override of the method: 867 | 868 | .. code-block:: python 869 | :linenos: 870 | 871 | async def get_friends(self): 872 | if not self._http_client.is_authenticated(): 873 | raise AuthenticationRequired() 874 | 875 | friends = self.retrieve_friends() 876 | return friends 877 | 878 | """ 879 | raise NotImplementedError() 880 | 881 | async def _start_game_times_import(self, game_ids: List[str]) -> None: 882 | await self._game_time_importer.start(game_ids) 883 | 884 | async def prepare_game_times_context(self, game_ids: List[str]) -> Any: 885 | """Override this method to prepare context for get_game_time. 886 | This allows for optimizations like batch requests to platform API. 887 | Default implementation returns None. 888 | 889 | :param game_ids: the ids of the games for which game time are imported 890 | :return: context 891 | """ 892 | return None 893 | 894 | async def get_game_time(self, game_id: str, context: Any) -> GameTime: 895 | """Override this method to return the game time for the game 896 | identified by the provided game_id. 897 | This method is called by import task initialized by GOG Galaxy Client. 898 | 899 | :param game_id: the id of the game for which the game time is returned 900 | :param context: the value returned from :meth:`prepare_game_times_context` 901 | :return: GameTime object 902 | """ 903 | raise NotImplementedError() 904 | 905 | def game_times_import_complete(self) -> None: 906 | """Override this method to handle operations after game times import is finished 907 | (like updating cache). 908 | """ 909 | 910 | async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: 911 | await self._game_library_settings_importer.start(game_ids) 912 | 913 | async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: 914 | """Override this method to prepare context for get_game_library_settings. 915 | This allows for optimizations like batch requests to platform API. 916 | Default implementation returns None. 917 | 918 | :param game_ids: the ids of the games for which game library settings are imported 919 | :return: context 920 | """ 921 | return None 922 | 923 | async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: 924 | """Override this method to return the game library settings for the game 925 | identified by the provided game_id. 926 | This method is called by import task initialized by GOG Galaxy Client. 927 | 928 | :param game_id: the id of the game for which the game library settings are imported 929 | :param context: the value returned from :meth:`prepare_game_library_settings_context` 930 | :return: GameLibrarySettings object 931 | """ 932 | raise NotImplementedError() 933 | 934 | def game_library_settings_import_complete(self) -> None: 935 | """Override this method to handle operations after game library settings import is finished 936 | (like updating cache). 937 | """ 938 | 939 | async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: 940 | await self._os_compatibility_importer.start(game_ids) 941 | 942 | async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: 943 | """Override this method to prepare context for get_os_compatibility. 944 | This allows for optimizations like batch requests to platform API. 945 | Default implementation returns None. 946 | 947 | :param game_ids: the ids of the games for which game os compatibility is imported 948 | :return: context 949 | """ 950 | return None 951 | 952 | async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: 953 | """Override this method to return the OS compatibility for the game with the provided game_id. 954 | This method is called by import task initialized by GOG Galaxy Client. 955 | 956 | :param game_id: the id of the game for which the game os compatibility is imported 957 | :param context: the value returned from :meth:`prepare_os_compatibility_context` 958 | :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know 959 | """ 960 | raise NotImplementedError() 961 | 962 | def os_compatibility_import_complete(self) -> None: 963 | """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" 964 | 965 | async def _start_user_presence_import(self, user_id_list: List[str]) -> None: 966 | await self._user_presence_importer.start(user_id_list) 967 | 968 | async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: 969 | """Override this method to prepare context for get_user_presence. 970 | This allows for optimizations like batch requests to platform API. 971 | Default implementation returns None. 972 | 973 | :param user_id_list: the ids of the users for whom presence information is imported 974 | :return: context 975 | """ 976 | return None 977 | 978 | async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: 979 | """Override this method to return presence information for the user with the provided user_id. 980 | This method is called by import task initialized by GOG Galaxy Client. 981 | 982 | :param user_id: the id of the user for whom presence information is imported 983 | :param context: the value returned from :meth:`prepare_user_presence_context` 984 | :return: UserPresence presence information of the provided user 985 | """ 986 | raise NotImplementedError() 987 | 988 | def user_presence_import_complete(self) -> None: 989 | """Override this method to handle operations after presence import is finished (like updating cache).""" 990 | 991 | 992 | def create_and_run_plugin(plugin_class, argv): 993 | """Call this method as an entry point for the implemented integration. 994 | 995 | :param plugin_class: your plugin class. 996 | :param argv: command line arguments with which the script was started. 997 | 998 | Example of possible use of the method: 999 | 1000 | .. code-block:: python 1001 | :linenos: 1002 | 1003 | def main(): 1004 | create_and_run_plugin(PlatformPlugin, sys.argv) 1005 | 1006 | if __name__ == "__main__": 1007 | main() 1008 | """ 1009 | if len(argv) < 3: 1010 | logger.critical("Not enough parameters, required: token, port") 1011 | sys.exit(1) 1012 | 1013 | token = argv[1] 1014 | 1015 | try: 1016 | port = int(argv[2]) 1017 | except ValueError: 1018 | logger.critical("Failed to parse port value: %s", argv[2]) 1019 | sys.exit(2) 1020 | 1021 | if not (1 <= port <= 65535): 1022 | logger.critical("Port value out of range (1, 65535)") 1023 | sys.exit(3) 1024 | 1025 | if not issubclass(plugin_class, Plugin): 1026 | logger.critical("plugin_class must be subclass of Plugin") 1027 | sys.exit(4) 1028 | 1029 | async def coroutine(): 1030 | reader, writer = await asyncio.open_connection("127.0.0.1", port) 1031 | try: 1032 | extra_info = writer.get_extra_info("sockname") 1033 | logger.info("Using local address: %s:%u", *extra_info) 1034 | async with plugin_class(reader, writer, token) as plugin: 1035 | await plugin.run() 1036 | finally: 1037 | writer.close() 1038 | await writer.wait_closed() 1039 | 1040 | 1041 | try: 1042 | if sys.platform == "win32": 1043 | asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) 1044 | 1045 | asyncio.run(coroutine()) 1046 | except Exception: 1047 | logger.exception("Error while running plugin") 1048 | sys.exit(5) 1049 | -------------------------------------------------------------------------------- /galaxy/api/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Dict, List, Optional 3 | 4 | from galaxy.api.consts import LicenseType, LocalGameState, PresenceState 5 | 6 | 7 | @dataclass 8 | class Authentication: 9 | """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` 10 | to inform the client that authentication has successfully finished. 11 | 12 | :param user_id: id of the authenticated user 13 | :param user_name: username of the authenticated user 14 | """ 15 | user_id: str 16 | user_name: str 17 | 18 | 19 | @dataclass 20 | class Cookie: 21 | """Cookie 22 | 23 | :param name: name of the cookie 24 | :param value: value of the cookie 25 | :param domain: optional domain of the cookie 26 | :param path: optional path of the cookie 27 | """ 28 | name: str 29 | value: str 30 | domain: Optional[str] = None 31 | path: Optional[str] = None 32 | 33 | 34 | @dataclass 35 | class NextStep: 36 | """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. 37 | For example: 38 | 39 | .. code-block:: python 40 | :linenos: 41 | 42 | PARAMS = { 43 | "window_title": "Login to platform", 44 | "window_width": 800, 45 | "window_height": 600, 46 | "start_uri": URL, 47 | "end_uri_regex": r"^https://platform_website\.com/.*" 48 | } 49 | 50 | JS = {r"^https://platform_website\.com/.*": [ 51 | r''' 52 | location.reload(); 53 | ''' 54 | ]} 55 | 56 | COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), 57 | Cookie("Cookie2", "ok", ".platform.com") 58 | ] 59 | 60 | async def authenticate(self, stored_credentials=None): 61 | if not stored_credentials: 62 | return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) 63 | 64 | :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, 65 | "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} 66 | :param cookies: browser initial set of cookies 67 | :param js: a map of the url regex patterns into the list of *js* scripts that should be executed 68 | on every document at given step of internal browser authentication. 69 | """ 70 | next_step: str 71 | auth_params: Dict[str, str] 72 | cookies: Optional[List[Cookie]] = None 73 | js: Optional[Dict[str, List[str]]] = None 74 | 75 | 76 | @dataclass 77 | class LicenseInfo: 78 | """Information about the license of related product. 79 | 80 | :param license_type: type of license 81 | :param owner: optional owner of the related product, defaults to currently authenticated user 82 | """ 83 | license_type: LicenseType 84 | owner: Optional[str] = None 85 | 86 | 87 | @dataclass 88 | class Dlc: 89 | """Downloadable content object. 90 | 91 | :param dlc_id: id of the dlc 92 | :param dlc_title: title of the dlc 93 | :param license_info: information about the license attached to the dlc 94 | """ 95 | dlc_id: str 96 | dlc_title: str 97 | license_info: LicenseInfo 98 | 99 | 100 | @dataclass 101 | class Game: 102 | """Game object. 103 | 104 | :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game 105 | :param game_title: title of the game 106 | :param dlcs: list of dlcs available for the game 107 | :param license_info: information about the license attached to the game 108 | """ 109 | game_id: str 110 | game_title: str 111 | dlcs: Optional[List[Dlc]] 112 | license_info: LicenseInfo 113 | 114 | 115 | @dataclass 116 | class Achievement: 117 | """Achievement, has to be initialized with either id or name. 118 | 119 | :param unlock_time: unlock time of the achievement 120 | :param achievement_id: optional id of the achievement 121 | :param achievement_name: optional name of the achievement 122 | """ 123 | unlock_time: int 124 | achievement_id: Optional[str] = None 125 | achievement_name: Optional[str] = None 126 | 127 | def __post_init__(self): 128 | assert self.achievement_id or self.achievement_name, \ 129 | "One of achievement_id or achievement_name is required" 130 | 131 | 132 | @dataclass 133 | class LocalGame: 134 | """Game locally present on the authenticated user's computer. 135 | 136 | :param game_id: id of the game 137 | :param local_game_state: state of the game 138 | """ 139 | game_id: str 140 | local_game_state: LocalGameState 141 | 142 | 143 | @dataclass 144 | class FriendInfo: 145 | """ 146 | .. deprecated:: 0.56 147 | Use :class:`UserInfo`. 148 | 149 | Information about a friend of the currently authenticated user. 150 | 151 | :param user_id: id of the user 152 | :param user_name: username of the user 153 | """ 154 | user_id: str 155 | user_name: str 156 | 157 | 158 | @dataclass 159 | class UserInfo: 160 | """Information about a user of related user. 161 | 162 | :param user_id: id of the user 163 | :param user_name: username of the user 164 | :param avatar_url: the URL of the user avatar 165 | :param profile_url: the URL of the user profile 166 | """ 167 | user_id: str 168 | user_name: str 169 | avatar_url: Optional[str] 170 | profile_url: Optional[str] 171 | 172 | 173 | @dataclass 174 | class GameTime: 175 | """Game time of a game, defines the total time spent in the game 176 | and the last time the game was played. 177 | 178 | :param game_id: id of the related game 179 | :param time_played: the total time spent in the game in **minutes** 180 | :param last_played_time: last time the game was played (**unix timestamp**) 181 | """ 182 | game_id: str 183 | time_played: Optional[int] 184 | last_played_time: Optional[int] 185 | 186 | 187 | @dataclass 188 | class GameLibrarySettings: 189 | """Library settings of a game, defines assigned tags and visibility flag. 190 | 191 | :param game_id: id of the related game 192 | :param tags: collection of tags assigned to the game 193 | :param hidden: indicates if the game should be hidden in GOG Galaxy client 194 | """ 195 | game_id: str 196 | tags: Optional[List[str]] 197 | hidden: Optional[bool] 198 | 199 | 200 | @dataclass 201 | class UserPresence: 202 | """Presence information of a user. 203 | 204 | The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) 205 | and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if 206 | available 207 | 208 | :param presence_state: the state of the user 209 | :param game_id: id of the game a user is currently in 210 | :param game_title: name of the game a user is currently in 211 | :param in_game_status: status set by the game itself e.x. "In Main Menu" 212 | :param full_status: full user status e.x. "Playing : " 213 | """ 214 | presence_state: PresenceState 215 | game_id: Optional[str] = None 216 | game_title: Optional[str] = None 217 | in_game_status: Optional[str] = None 218 | full_status: Optional[str] = None 219 | -------------------------------------------------------------------------------- /galaxy/http.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. 3 | 4 | It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. 5 | Examplary simple web service could looks like: 6 | 7 | .. code-block:: python 8 | 9 | import logging 10 | from galaxy.http import create_client_session, handle_exception 11 | 12 | class BackendClient: 13 | AUTH_URL = 'my-integration.com/auth' 14 | HEADERS = { 15 | "My-Custom-Header": "true", 16 | } 17 | def __init__(self): 18 | self._session = create_client_session(headers=self.HEADERS) 19 | 20 | async def authenticate(self): 21 | await self._session.request('POST', self.AUTH_URL) 22 | 23 | async def close(self): 24 | # to be called on plugin shutdown 25 | await self._session.close() 26 | 27 | async def _authorized_request(self, method, url, *args, **kwargs): 28 | with handle_exceptions(): 29 | return await self._session.request(method, url, *args, **kwargs) 30 | """ 31 | 32 | import asyncio 33 | import ssl 34 | from contextlib import contextmanager 35 | from http import HTTPStatus 36 | 37 | import aiohttp 38 | import certifi 39 | import logging 40 | 41 | from galaxy.api.errors import ( 42 | AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, 43 | TooManyRequests, UnknownBackendResponse, UnknownError 44 | ) 45 | 46 | 47 | logger = logging.getLogger(__name__) 48 | 49 | #: Default limit of the simultaneous connections for ssl connector. 50 | DEFAULT_LIMIT = 20 51 | #: Default timeout in seconds used for client session. 52 | DEFAULT_TIMEOUT = 60 53 | 54 | 55 | class HttpClient: 56 | """ 57 | .. deprecated:: 0.41 58 | Use http module functions instead 59 | """ 60 | def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): 61 | connector = create_tcp_connector(limit=limit) 62 | self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) 63 | 64 | async def close(self): 65 | """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" 66 | await self._session.close() 67 | 68 | async def request(self, method, url, *args, **kwargs): 69 | with handle_exception(): 70 | return await self._session.request(method, url, *args, **kwargs) 71 | 72 | 73 | def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: 74 | """ 75 | Creates TCP connector with resonable defaults. 76 | For details about available parameters refer to 77 | `aiohttp.TCPConnector `_ 78 | """ 79 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 80 | ssl_context.load_verify_locations(certifi.where()) 81 | kwargs.setdefault("ssl", ssl_context) 82 | kwargs.setdefault("limit", DEFAULT_LIMIT) 83 | # due to https://github.com/python/mypy/issues/4001 84 | return aiohttp.TCPConnector(*args, **kwargs) # type: ignore 85 | 86 | 87 | def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: 88 | """ 89 | Creates client session with resonable defaults. 90 | For details about available parameters refer to 91 | `aiohttp.ClientSession `_ 92 | 93 | Examplary customization: 94 | 95 | .. code-block:: python 96 | 97 | from galaxy.http import create_client_session, create_tcp_connector 98 | 99 | session = create_client_session( 100 | headers={ 101 | "Keep-Alive": "true" 102 | }, 103 | connector=create_tcp_connector(limit=40), 104 | timeout=100) 105 | """ 106 | kwargs.setdefault("connector", create_tcp_connector()) 107 | kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) 108 | kwargs.setdefault("raise_for_status", True) 109 | # due to https://github.com/python/mypy/issues/4001 110 | return aiohttp.ClientSession(*args, **kwargs) # type: ignore 111 | 112 | 113 | @contextmanager 114 | def handle_exception(): 115 | """ 116 | Context manager translating network related exceptions 117 | to custom :mod:`~galaxy.api.errors`. 118 | """ 119 | try: 120 | yield 121 | except asyncio.TimeoutError: 122 | raise BackendTimeout() 123 | except aiohttp.ServerDisconnectedError: 124 | raise BackendNotAvailable() 125 | except aiohttp.ClientConnectionError: 126 | raise NetworkError() 127 | except aiohttp.ContentTypeError: 128 | raise UnknownBackendResponse() 129 | except aiohttp.ClientResponseError as error: 130 | if error.status == HTTPStatus.UNAUTHORIZED: 131 | raise AuthenticationRequired() 132 | if error.status == HTTPStatus.FORBIDDEN: 133 | raise AccessDenied() 134 | if error.status == HTTPStatus.SERVICE_UNAVAILABLE: 135 | raise BackendNotAvailable() 136 | if error.status == HTTPStatus.TOO_MANY_REQUESTS: 137 | raise TooManyRequests() 138 | if error.status >= 500: 139 | raise BackendError() 140 | if error.status >= 400: 141 | logger.warning( 142 | "Got status %d while performing %s request for %s", 143 | error.status, error.request_info.method, str(error.request_info.url) 144 | ) 145 | raise UnknownError() 146 | except aiohttp.ClientError: 147 | logger.exception("Caught exception while performing request") 148 | raise UnknownError() 149 | -------------------------------------------------------------------------------- /galaxy/proc_tools.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from dataclasses import dataclass 3 | from typing import Iterable, NewType, Optional, List, cast 4 | 5 | 6 | ProcessId = NewType("ProcessId", int) 7 | 8 | 9 | @dataclass 10 | class ProcessInfo: 11 | pid: ProcessId 12 | binary_path: Optional[str] 13 | 14 | 15 | if sys.platform == "win32": 16 | from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError 17 | from ctypes.wintypes import DWORD 18 | 19 | 20 | def pids() -> Iterable[ProcessId]: 21 | _PROC_ID_T = DWORD 22 | list_size = 4096 23 | 24 | def try_get_pids(list_size: int) -> List[ProcessId]: 25 | result_size = DWORD() 26 | proc_id_list = (_PROC_ID_T * list_size)() 27 | 28 | if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): 29 | raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore 30 | 31 | return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) 32 | 33 | while True: 34 | proc_ids = try_get_pids(list_size) 35 | if len(proc_ids) < list_size: 36 | return proc_ids 37 | 38 | list_size *= 2 39 | 40 | 41 | def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: 42 | _PROC_QUERY_LIMITED_INFORMATION = 0x1000 43 | 44 | process_info = ProcessInfo(pid=pid, binary_path=None) 45 | 46 | h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) 47 | if not h_process: 48 | return process_info 49 | 50 | try: 51 | def get_exe_path() -> Optional[str]: 52 | _MAX_PATH = 260 53 | _WIN32_PATH_FORMAT = 0x0000 54 | 55 | exe_path_buffer = create_unicode_buffer(_MAX_PATH) 56 | exe_path_len = DWORD(len(exe_path_buffer)) 57 | 58 | return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( 59 | h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) 60 | ) else None 61 | 62 | process_info.binary_path = get_exe_path() 63 | finally: 64 | windll.kernel32.CloseHandle(h_process) 65 | return process_info 66 | else: 67 | import psutil 68 | 69 | 70 | def pids() -> Iterable[ProcessId]: 71 | for pid in psutil.pids(): 72 | yield pid 73 | 74 | 75 | def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: 76 | process_info = ProcessInfo(pid=pid, binary_path=None) 77 | try: 78 | process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] 79 | except psutil.NoSuchProcess: 80 | pass 81 | finally: 82 | return process_info 83 | 84 | 85 | def process_iter() -> Iterable[Optional[ProcessInfo]]: 86 | for pid in pids(): 87 | yield get_process_info(pid) 88 | -------------------------------------------------------------------------------- /galaxy/reader.py: -------------------------------------------------------------------------------- 1 | from asyncio import StreamReader 2 | 3 | 4 | class StreamLineReader: 5 | """Handles StreamReader readline without buffer limit""" 6 | def __init__(self, reader: StreamReader): 7 | self._reader = reader 8 | self._buffer = bytes() 9 | self._processed_buffer_it = 0 10 | 11 | async def readline(self): 12 | while True: 13 | # check if there is no unprocessed data in the buffer 14 | if not self._buffer or self._processed_buffer_it != 0: 15 | chunk = await self._reader.read(1024*1024) 16 | if not chunk: 17 | return bytes() # EOF 18 | self._buffer += chunk 19 | 20 | it = self._buffer.find(b"\n", self._processed_buffer_it) 21 | if it < 0: 22 | self._processed_buffer_it = len(self._buffer) 23 | continue 24 | 25 | line = self._buffer[:it] 26 | self._buffer = self._buffer[it+1:] 27 | self._processed_buffer_it = 0 28 | return line 29 | -------------------------------------------------------------------------------- /galaxy/registry_monitor.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | if sys.platform == "win32": 5 | import logging 6 | import ctypes 7 | from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID 8 | 9 | LPSECURITY_ATTRIBUTES = LPVOID 10 | 11 | RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW 12 | RegOpenKeyEx.restype = LONG 13 | RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] 14 | 15 | RegCloseKey = ctypes.windll.advapi32.RegCloseKey 16 | RegCloseKey.restype = LONG 17 | RegCloseKey.argtypes = [HKEY] 18 | 19 | RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue 20 | RegNotifyChangeKeyValue.restype = LONG 21 | RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] 22 | 23 | CloseHandle = ctypes.windll.kernel32.CloseHandle 24 | CloseHandle.restype = BOOL 25 | CloseHandle.argtypes = [HANDLE] 26 | 27 | CreateEvent = ctypes.windll.kernel32.CreateEventW 28 | CreateEvent.restype = BOOL 29 | CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] 30 | 31 | WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject 32 | WaitForSingleObject.restype = DWORD 33 | WaitForSingleObject.argtypes = [HANDLE, DWORD] 34 | 35 | ERROR_SUCCESS = 0x00000000 36 | 37 | KEY_READ = 0x00020019 38 | KEY_QUERY_VALUE = 0x00000001 39 | 40 | REG_NOTIFY_CHANGE_NAME = 0x00000001 41 | REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 42 | 43 | WAIT_OBJECT_0 = 0x00000000 44 | WAIT_TIMEOUT = 0x00000102 45 | 46 | class RegistryMonitor: 47 | 48 | def __init__(self, root, subkey): 49 | self._root = root 50 | self._subkey = subkey 51 | self._event = CreateEvent(None, False, False, None) 52 | 53 | self._key = None 54 | self._open_key() 55 | if self._key: 56 | self._set_key_update_notification() 57 | 58 | def close(self): 59 | CloseHandle(self._event) 60 | if self._key: 61 | RegCloseKey(self._key) 62 | self._key = None 63 | 64 | def is_updated(self): 65 | wait_result = WaitForSingleObject(self._event, 0) 66 | 67 | # previously watched 68 | if wait_result == WAIT_OBJECT_0: 69 | self._set_key_update_notification() 70 | return True 71 | 72 | # no changes or no key before 73 | if wait_result != WAIT_TIMEOUT: 74 | # unexpected error 75 | logging.warning("Unexpected WaitForSingleObject result %s", wait_result) 76 | return False 77 | 78 | if self._key is None: 79 | self._open_key() 80 | 81 | if self._key is not None: 82 | self._set_key_update_notification() 83 | 84 | return False 85 | 86 | def _set_key_update_notification(self): 87 | filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET 88 | status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) 89 | if status != ERROR_SUCCESS: 90 | # key was deleted 91 | RegCloseKey(self._key) 92 | self._key = None 93 | 94 | def _open_key(self): 95 | access = KEY_QUERY_VALUE | KEY_READ 96 | self._key = HKEY() 97 | rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) 98 | if rc != ERROR_SUCCESS: 99 | self._key = None 100 | -------------------------------------------------------------------------------- /galaxy/task_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from collections import OrderedDict 4 | from itertools import count 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class TaskManager: 11 | def __init__(self, name): 12 | self._name = name 13 | self._tasks = OrderedDict() 14 | self._task_counter = count() 15 | 16 | def create_task(self, coro, description, handle_exceptions=True): 17 | """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" 18 | 19 | async def task_wrapper(task_id): 20 | try: 21 | result = await coro 22 | logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) 23 | return result 24 | except asyncio.CancelledError: 25 | if handle_exceptions: 26 | logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) 27 | else: 28 | raise 29 | except Exception: 30 | if handle_exceptions: 31 | logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) 32 | else: 33 | raise 34 | finally: 35 | del self._tasks[task_id] 36 | 37 | task_id = next(self._task_counter) 38 | logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) 39 | task = asyncio.create_task(task_wrapper(task_id)) 40 | self._tasks[task_id] = task 41 | return task 42 | 43 | def cancel(self): 44 | for task in self._tasks.values(): 45 | task.cancel() 46 | 47 | async def wait(self): 48 | # Tasks can spawn other tasks 49 | while True: 50 | tasks = self._tasks.values() 51 | if not tasks: 52 | return 53 | await asyncio.gather(*tasks, return_exceptions=True) 54 | -------------------------------------------------------------------------------- /galaxy/tools.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import zipfile 4 | from glob import glob 5 | 6 | 7 | def zip_folder(folder): 8 | files = glob(os.path.join(folder, "**"), recursive=True) 9 | files = [file.replace(folder + os.sep, "") for file in files] 10 | files = [file for file in files if file] 11 | 12 | zip_buffer = io.BytesIO() 13 | with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: 14 | for file in files: 15 | zipf.write(os.path.join(folder, file), arcname=file) 16 | return zip_buffer 17 | 18 | 19 | def zip_folder_to_file(folder, filename): 20 | zip_content = zip_folder(folder).getbuffer() 21 | with open(filename, "wb") as archive: 22 | archive.write(zip_content) 23 | -------------------------------------------------------------------------------- /galaxy/unittest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JTNDev/galaxy-integration-wii/a8fb27925c3003997a06c2384a3255bb1b4f7786/galaxy/unittest/__init__.py -------------------------------------------------------------------------------- /galaxy/unittest/mock.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from unittest.mock import MagicMock 3 | 4 | 5 | class AsyncMock(MagicMock): 6 | """ 7 | .. deprecated:: 0.45 8 | Use: :class:`MagicMock` with meth:`~.async_return_value`. 9 | """ 10 | async def __call__(self, *args, **kwargs): 11 | return super(AsyncMock, self).__call__(*args, **kwargs) 12 | 13 | 14 | def coroutine_mock(): 15 | """ 16 | .. deprecated:: 0.45 17 | Use: :class:`MagicMock` with meth:`~.async_return_value`. 18 | """ 19 | coro = MagicMock(name="CoroutineResult") 20 | corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) 21 | corofunc.coro = coro 22 | return corofunc 23 | 24 | 25 | async def skip_loop(iterations=1): 26 | for _ in range(iterations): 27 | await asyncio.sleep(0) 28 | 29 | 30 | async def async_return_value(return_value, loop_iterations_delay=0): 31 | if loop_iterations_delay > 0: 32 | await skip_loop(loop_iterations_delay) 33 | return return_value 34 | 35 | 36 | async def async_raise(error, loop_iterations_delay=0): 37 | if loop_iterations_delay > 0: 38 | await skip_loop(loop_iterations_delay) 39 | raise error 40 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dolphin for GOG Galaxy 2.0", 3 | "platform": "nwii", 4 | "guid": "fc3e85e4-c66b-4310-96c0-8f95cc43e546", 5 | "version": "0.5.1", 6 | "description": "GameCube Dolphin Plugin for GOG Galaxy 2.0", 7 | "author": "JTNDev", 8 | "email": "thejokister@gmail.com", 9 | "url": "https://github.com/JTNDev/galaxy-integration-wii/blob/master/manifest.json", 10 | "script": "plugin.py" 11 | } 12 | -------------------------------------------------------------------------------- /plugin.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from subprocess import Popen 4 | import sys 5 | from shutil import copyfile 6 | from xml.etree import ElementTree 7 | 8 | import user_config 9 | from backend import BackendClient 10 | from galaxy.api.consts import LicenseType, LocalGameState, Platform 11 | from galaxy.api.plugin import Plugin, create_and_run_plugin 12 | from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime 13 | from version import __version__ 14 | import time 15 | 16 | class DolphinPlugin(Plugin): 17 | 18 | def __init__(self, reader, writer, token): 19 | super().__init__(Platform.NintendoWii, __version__, reader, writer, token) 20 | self.backend_client = BackendClient() 21 | self.games = [] 22 | if not os.path.exists(os.path.dirname(os.path.realpath(__file__)) + r'\gametimes.xml'): 23 | copyfile(os.path.dirname(os.path.realpath(__file__)) + r'\files\gametimes.xml', os.path.dirname(os.path.realpath(__file__)) + r'\gametimes.xml') 24 | self.game_times = self.get_the_game_times() 25 | self.local_games_cache = self.local_games_list() 26 | self.runningGame = self.runningGame = {"game_id": "", "starting_time": 0, "dolphin_running": None, "launched": False} 27 | 28 | async def authenticate(self, stored_credentials=None): 29 | return self.do_auth() 30 | 31 | def get_the_game_times(self): 32 | file = ElementTree.parse(os.path.dirname(os.path.realpath(__file__)) + r'\gametimes.xml') 33 | game_times = {} 34 | games_xml = file.getroot() 35 | for game in games_xml.iter('game'): 36 | game_id = str(game.find('id').text) 37 | tt = game.find('time').text 38 | ltp = game.find('lasttimeplayed').text 39 | game_times[game_id] = [tt, ltp] 40 | return game_times 41 | 42 | async def pass_login_credentials(self, step, credentials, cookies): 43 | return self.do_auth() 44 | 45 | def do_auth(self): 46 | user_data = {} 47 | username = user_config.roms_path 48 | user_data["username"] = username 49 | self.store_credentials(user_data) 50 | return Authentication("Dolphin", user_data["username"]) 51 | 52 | async def launch_game(self, game_id): 53 | emu_path = user_config.emu_path 54 | for game in self.games: 55 | if game.id == game_id: 56 | if not user_config.retroarch: 57 | openDolphin = Popen([emu_path, "-b", "-e", game.path]) 58 | gameStartingTime = time.time() 59 | self.runningGame = {"game_id": game_id, "starting_time": gameStartingTime, "dolphin_running": openDolphin} 60 | else: 61 | Popen([user_config.retroarch_executable, "-L", user_config.core_path + r'\dolphin_libretro.dll', game.path]) 62 | break 63 | return 64 | 65 | async def install_game(self, game_id): 66 | pass 67 | 68 | async def uninstall_game(self, game_id): 69 | pass 70 | 71 | async def get_game_time(self, game_id, context=None): 72 | game_times = self.game_times 73 | game_time = int(game_times[game_id][0]) 74 | game_time /= 60 75 | return GameTime(game_id, game_time, game_times[game_id][1]) 76 | 77 | def local_games_list(self): 78 | local_games = [] 79 | for game in self.games: 80 | local_games.append( 81 | LocalGame( 82 | game.id, 83 | LocalGameState.Installed 84 | ) 85 | ) 86 | return local_games 87 | 88 | def tick(self): 89 | 90 | async def update_local_games(): 91 | loop = asyncio.get_running_loop() 92 | new_local_games_list = await loop.run_in_executor(None, self.local_games_list) 93 | notify_list = self.backend_client.get_state_changes(self.local_games_cache, new_local_games_list) 94 | self.local_games_cache = new_local_games_list 95 | for local_game_notify in notify_list: 96 | self.update_local_game_status(local_game_notify) 97 | 98 | file = ElementTree.parse(os.path.dirname(os.path.realpath(__file__)) + r'\gametimes.xml') 99 | if self.runningGame["dolphin_running"] is not None: 100 | if self.runningGame["dolphin_running"].poll() is None: 101 | self.runningGame["launched"] = True 102 | if self.runningGame["dolphin_running"].poll() is not None: 103 | if self.runningGame["launched"]: 104 | current_time = round(time.time()) 105 | runtime = time.time() - self.runningGame["starting_time"] 106 | games_xml = file.getroot() 107 | for game in games_xml.iter('game'): 108 | if str(game.find('id').text) == self.runningGame["game_id"]: 109 | previous_time = int(game.find('time').text) 110 | total_time = round(previous_time + runtime) 111 | game.find('time').text = str(total_time) 112 | game.find('lasttimeplayed').text = str(current_time) 113 | self.update_game_time( 114 | GameTime(self.runningGame["game_id"], int(total_time / 60), current_time)) 115 | file.write(os.path.dirname(os.path.realpath(__file__)) + r'\gametimes.xml') 116 | self.runningGame["launched"] = False 117 | 118 | asyncio.create_task(update_local_games()) 119 | 120 | async def get_owned_games(self): 121 | self.games = self.backend_client.get_games_db() 122 | owned_games = [] 123 | 124 | for game in self.games: 125 | owned_games.append( 126 | Game( 127 | game.id, 128 | game.name, 129 | None, 130 | LicenseInfo(LicenseType.SinglePurchase, None) 131 | ) 132 | ) 133 | return owned_games 134 | 135 | async def get_local_games(self): 136 | return self.local_games_cache 137 | 138 | def shutdown(self): 139 | pass 140 | 141 | def main(): 142 | create_and_run_plugin(DolphinPlugin, sys.argv) 143 | 144 | # run plugin event loop 145 | if __name__ == "__main__": 146 | main() 147 | -------------------------------------------------------------------------------- /user_config.py: -------------------------------------------------------------------------------- 1 | # Set your roms inside the string/quotes 2 | roms_path = r"C:\example\path\WiiROMS" 3 | # Set the path to your Dolphin.exe inside the string/quotes 4 | emu_path = r"C:\example\path\Dolphin.exe" 5 | 6 | #Enable to allow matching by ID, best_match_game_detection can be used as fallback 7 | match_by_id = True 8 | #Enable to allow the best match algorithm instead of exact game name 9 | best_match_game_detection = True 10 | 11 | # Retroarch Settings 12 | 13 | # Put True if you want Retroarch support, False if you don't 14 | retroarch = False 15 | # Your Retroarch core directory (usually C:\Users\(yourusernamehere)\AppData\Roaming\RetroArch\cores) 16 | core_path = r"C:\Users\\AppData\Roaming\RetroArch\cores" 17 | # Your Retroarch exe (usually C:\Users\(yourusernamehere)\AppData\Roaming\RetroArch\cores) 18 | retroarch_executable = r"C:\Users\\AppData\Roaming\RetroArch\retroarch.exe" 19 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.1" --------------------------------------------------------------------------------