├── .dockerignore ├── .env-sample ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── recommended.settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── config-sample-windows.yaml ├── config-sample.yaml ├── doc └── images │ ├── gamatrix-front-page.png │ ├── gamatrix-game-grid.png │ └── gamatrix-game-list.png ├── justfile ├── pyproject.toml ├── src └── gamatrix │ ├── __init__.py │ ├── __main__.py │ ├── helpers │ ├── __init__.py │ ├── cache_helper.py │ ├── constants.py │ ├── gogdb_helper.py │ ├── igdb_helper.py │ ├── misc_helper.py │ └── network_helper.py │ ├── static │ ├── __init__.py │ ├── battlenet.png │ ├── bethesda.png │ ├── epic.png │ ├── gog.png │ ├── origin.png │ ├── profile_img │ │ ├── __init__.py │ │ └── question_block.jpg │ ├── steam.png │ ├── uplay.png │ └── xboxone.png │ └── templates │ ├── __init__.py │ ├── game_grid.html.jinja │ ├── game_list.html.jinja │ ├── index.html.jinja │ └── upload_status.html.jinja └── test ├── test_cmdline.py └── test_gogdb.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .cache* 3 | *.json 4 | config* 5 | Dockerfile 6 | .dockerignore 7 | .git* 8 | images 9 | out* 10 | tmp* 11 | venv 12 | .vscode -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | # These are loaded as env vars when a just reciped is executed. 2 | # You can't do anything fancy here, e.g. the following will NOT work: 3 | # VAR=$SOME_OTHER_VAR_DEFINED_IN_HERE 4 | # VAR=$(echo foo) 5 | 6 | # Paths on the host that will be mounted into the container for "just run" 7 | PROD_GOG_DBS="$HOME/gamatrix/gog_dbs" 8 | PROD_CONFIG="$HOME/gamatrix-configs/config.yaml" 9 | PROD_CACHE="$HOME/gamatrix/.cache.json" 10 | # This should match the port set in $PROD_CONFIG; default is 80 if not set 11 | PROD_PORT="80" 12 | 13 | # Paths on the host that will be mounted into the container for "just dev" 14 | DEV_GOG_DBS="$HOME/gamatrix/gog_dbs" 15 | DEV_CONFIG="$HOME/gamatrix-configs/config.yaml" 16 | DEV_CACHE="$HOME/gamatrix/.cache.json" 17 | # This should match the port set in $DEV_CONFIG; default is 8080 if not set 18 | DEV_PORT="8080" 19 | 20 | # Optional bash aliases, set -o vi, etc. that will be executed in the container for "just dev" 21 | BASHRC_USER="$HOME/.gamatrix/.bashrc.user" 22 | # Time zone inside the container; must be in /usr/share/zoneinfo in the container. 23 | # This will be used when showing the timestamps of the DBs on the main page. 24 | # If it's unset or not set correctly, UTC will be used 25 | TZ="America/Vancouver" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Basic .gitattributes for a python repo. 2 | 3 | # Source files 4 | # ============ 5 | *.pxd text diff=python 6 | *.py text diff=python 7 | *.py3 text diff=python 8 | *.pyc text diff=python 9 | *.pyd text diff=python 10 | *.pyo text diff=python 11 | *.pyw text diff=python 12 | *.pyx text diff=python 13 | *.pyz text diff=python 14 | 15 | # Binary files 16 | # ============ 17 | *.db binary 18 | *.p binary 19 | *.pkl binary 20 | *.pickle binary 21 | *.pyc binary 22 | *.pyd binary 23 | *.pyo binary 24 | 25 | # Jupyter notebook 26 | *.ipynb text 27 | 28 | # Note: .db, .p, and .pkl files are associated 29 | # with the python modules ``pickle``, ``dbm.*``, 30 | # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` 31 | # (among others). 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | jobs: 13 | tests: 14 | 15 | name: Test Python ${{ matrix.python_version }} 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python_version: ['3.9'] 20 | env: 21 | PYTHONDEVMODE: 1 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - name: Set up Python 27 | uses: actions/setup-python@v3 28 | with: 29 | python-version: ${{ matrix.python_version }} 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install -U pip 34 | python -m pip install .[dev,ci] 35 | 36 | - name: Analyze with mypy 37 | run: | 38 | python -m mypy 39 | 40 | - name: Check format with Black 41 | run: | 42 | python -m black --check . 43 | 44 | - name: Test with pytest 45 | run: | 46 | python -m pytest 47 | 48 | - name: Build a Wheel 49 | run: | 50 | python -m build --wheel 51 | 52 | - uses: actions/upload-artifact@v3 53 | with: 54 | name: wheel 55 | path: dist/gamatrix-*-none-any.whl 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 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 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | .venv*/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | 128 | # VSCode debugging and settings are machine specific, let's not check them in. 129 | .vscode/launch.json 130 | .vscode/settings.json 131 | 132 | # User's steam dev api key file 133 | .user_steam_api_dev_key 134 | 135 | # User's config file 136 | config.yaml 137 | config-local.yaml 138 | .cache.json 139 | 140 | # Static files for flask, include logo images but not profile pics 141 | profile_img/ 142 | 143 | tmp/ 144 | -------------------------------------------------------------------------------- /.vscode/recommended.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "editor.formatOnPaste": true, 4 | "editor.formatOnSave": true, 5 | "python.testing.pytestEnabled": true, 6 | "python.linting.flake8Enabled": false, 7 | "python.linting.mypyEnabled": true, 8 | "python.testing.pytestArgs": [ 9 | "test" 10 | ], 11 | "python.testing.unittestEnabled": false, 12 | "python.testing.nosetestsEnabled": false 13 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | WORKDIR /usr/src/app 4 | 5 | # Limit what we copy to keep image size down. 6 | # We only need the src/ folder and the pyproject.toml file. 7 | COPY pyproject.toml src ./ 8 | 9 | # Build and then install the gamatrix package only. 10 | RUN python -m pip install -U pip && \ 11 | python -m pip install build && \ 12 | python -m build --wheel && \ 13 | python -m pip install dist/gamatrix-*.whl && \ 14 | # Clean up work folder 15 | rm -rf /usr/src/app/* && \ 16 | # Create config and data directories mounted in the Docker run command. (See README for details). 17 | mkdir /usr/src/app/gog_dbs /usr/src/app/config 18 | 19 | # This is used by "just dev" 20 | RUN echo '[ -e /root/.bashrc.user ] && . /root/.bashrc.user' >> /root/.bashrc 21 | 22 | CMD [ "python", "-m", "gamatrix", "-c", "/usr/src/app/config.yaml", "-p", "80", "-s" ] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gamatrix 2 | 3 | [![CI](https://github.com/eniklas/gamatrix/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/eniklas/gamatrix/actions/workflows/ci.yml) 4 | 5 | ## Quick start 6 | 7 | Jump to [command-line mode](#command-line-mode) or [building with Docker](#running-in-docker). 8 | 9 | ## Introduction 10 | 11 | Gamatrix is a tool to compare the games owned by several users, and list all the games they have in common. It requires all users to use [GOG Galaxy](https://www.gog.com/galaxy); since GOG Galaxy supports almost all major digital distribution platforms through integrations, it's a great service for aggregating your games in one place. Gamatrix uses the SQLite database that GOG Galaxy stores locally to pull its data from; users can upload their DBs via the main page or with a provided script. 12 | 13 | ### Features 14 | 15 | * compares the game libraries of an arbitrary number of users, with several filtering options 16 | * multiplayer support and max players autopopulated from IGDB when available 17 | * option to pick a random game 18 | * configuration via YAML file and/or command-line options 19 | * optional Docker container 20 | * IP whitelisting support 21 | * ability to upload DBs 22 | 23 | ### Screen shots 24 | 25 | #### Front page 26 | 27 | Select your search criteria from the front page: 28 | 29 | ![Selection page](/doc/images/gamatrix-front-page.png) 30 | 31 | #### Upload DB 32 | 33 | Upload a new GOG DB. Only files with a `.db` extension are allowed; the requesting IP is used to identify the uploader and name the target file correctly. To upload from a script, you can download [curl for Windows](https://curl.se/windows/) and run the following batch script, replacing `your-gamatrix-url` with your server's DNS name or IP address: 34 | 35 | ```bat 36 | curl -F file=@"C:\ProgramData\GOG.com\Galaxy\storage\galaxy-2.0.db" http:///compare?option=upload 37 | pause 38 | ``` 39 | 40 | To set up a scheduled task to upload your DB automatically, save the above script without the `pause` line; for example, on your desktop with the name `gamatrix-upload-nopause.bat`. Then, open a command prompt and run: 41 | 42 | ```cmd 43 | SCHTASKS /CREATE /SC DAILY /TN "gamatrix DB upload" /TR "%USERPROFILE%\Desktop\gamatrix-upload-nopause.bat" /ST 21:00 44 | ``` 45 | 46 | where `21:00` is the time of day the task will run, which should be a time you're typically logged in. You may also want to set the option "Run task as soon as possible after a scheduled start is missed", which you can find in Task Scheduler under the settings for your task; this option is not available from the command line. 47 | 48 | #### Game list 49 | 50 | The `Game list` option provides a list of games owned by the selected users: 51 | 52 | ![Game list](/doc/images/gamatrix-game-list.png) 53 | 54 | * titles supporting fewer players than selected are greyed out 55 | * under `Installed`, a check mark indicates all players have the game installed; otherwise the names (or profile pics, if available) of the users that have the game installed are shown 56 | 57 | #### Game grid 58 | 59 | The Game grid option shows all games owned by the selected users 60 | 61 | ![Game grid](/doc/images/gamatrix-game-grid.png) 62 | 63 | * green cells indicate the user owns the game, red indicates they don't 64 | * a check mark means the user has the game installed 65 | 66 | ## Usage 67 | 68 | ```pre 69 | gamatrix 70 | Show and compare between games owned by multiple users. 71 | 72 | Usage: 73 | gamatrix.py --help 74 | gamatrix.py --version 75 | gamatrix.py [--config-file=CFG] [--debug] [--all-games] [--interface=IFC] [--installed-only] [--include-single-player] [--port=PORT] [--server] [--update-cache] [--userid=UID ...] [ ... ] 76 | 77 | Options: 78 | -h, --help Show this help message and exit. 79 | -v, --version Print version and exit. 80 | -c CFG, --config-file=CFG The config file to use. 81 | -d, --debug Print out verbose debug output. 82 | -a, --all-games List all games owned by the selected users (doesn't include single player unless -S is used). 83 | -i IFC, --interface=IFC The network interface to use if running in server mode; default is 0.0.0.0. 84 | -I, --installed-only Only show games installed by all users. 85 | -p PORT, --port=PORT The network port to use if running in server mode; default is 8080. 86 | -s, --server Run in server mode. 87 | -S, --include-single-player Include single player games. 88 | -U, --update-cache Update cache entries that have incomplete info. 89 | -u USERID, --userid=USERID The GOG user IDs to compare, there can be multiples of this switch. 90 | 91 | Positional Arguments: 92 | The GOG DB for a user, multiple can be listed. 93 | ``` 94 | 95 | `db`: a GOG database to use. You can usually find a user's DB in `C:\ProgramData\GOG.com\Galaxy\storage\galaxy-2.0.db`. Multiple DBs can be listed. Not compatible with `-u`. 96 | 97 | `-a/--all-games`: list all the multiplayer games owned by the selected users (user selection is covered below). Use with `-I` to include single-player games too. 98 | 99 | `-c/--config-file`: the YAML config file to use. You don't need a config file, but you'll likely want one. See [configuration](#configuration) for details. 100 | 101 | `-d/--debug`: enable debug messages. 102 | 103 | `-s/--server`: run in server mode. This will use Flask to serve a small web page where you can select the options you want, and will output the results there. 104 | 105 | `-S/--include-single-player`: include single-player games; by default, only multiplayer games are shown. 106 | 107 | `-u/--userid`: a list of GOG user IDs to compare. The IDs must be in the [config file](#configuration). You can find the user ID by running `sqlite3 /path/to/galaxy-2.0.db "select * from Users;"`. If you use this option, you can't list DBs; they must be provided for the user IDs in the config file. 108 | 109 | `-U/--update-cache`: pull info from IGDB for titles that previously got incomplete data, e.g. no info on multiplayer support, or no max player data. For instance, if you contribute max player info for a title in IGDB, once it's approved you can use this to pull it into your cache. 110 | 111 | ### Command-line mode 112 | 113 | Command-line mode lists the output to a terminal, and doesn't use Flask. It's a good way to get copy/pastable titles to put into the config file for titles you want to hide or add metadata for. This mode is not developed as heavily as server mode, so may lag behind in features. To run, clone this repo and: 114 | 115 | **1. Setup your virtual environment:** 116 | 117 | **Linux/MacOS** 118 | 119 | ```bash 120 | python3 -m venv venv 121 | . venv/bin/activate 122 | ``` 123 | 124 | **Windows** 125 | 126 | ```pwsh 127 | py -3 -m venv venv 128 | .venv\Scripts\Activate.ps1 129 | ``` 130 | 131 | **2. Install dependencies:** 132 | 133 | ```bash 134 | python -m pip install -U pip 135 | python -m pip install . 136 | python -m gamatrix [args] 137 | ``` 138 | 139 | ### Server mode 140 | 141 | If you use the `-s` option or set `mode: server` in the [config file](#configuration), a Flask web server is started and runs until the script is killed. This serves a web page with a check box for all users defined in the config file; users can select who they want to compare and a table is generated for the matching games. There is also a game grid layout that shows all games with color-coded cells indicating who owns which games. 142 | 143 | Server mode is the intended use case, and supports all options, unlike CLI mode, which may not. 144 | 145 | ## Configuration 146 | 147 | A YAML file provides the runtime configuration; by default, this is `config.yaml` in the same directory as the script, but this can be overridden with the `-c` option. See the annotated [sample file](config-sample.yaml) or the [Windows flavour of this file](config-sample-windows.yaml) for an explanation of the format. 148 | 149 | ### IGDB 150 | 151 | [IGDB](https://www.igdb.com) will be used to pull multiplayer info if you have the needed credentials. See [this page](https://api-docs.igdb.com/#account-creation) for instructions on getting a client ID and secret, and put these in your config file as `igdb_client_id` and `igdb_client_secret`. Once this is set up, IGDB will be checked for all titles the first time they're processed, and if available, will categorize the title as multiplayer or single player, and set the maximum players. Note that this takes about a second per title, so the first time you use it, it can take a long time. The data is saved to disk in a cache file, which is read each time gamatrix is launched, so once the cache is populated it's quite fast. 152 | 153 | Gamatrix respects the IGDB rate limit and auto-renews your access token, so once you set your ID and secret in your config you should be good to go. If you have issues, debug by running in CLI mode with the `-d` option. 154 | 155 | ## Just recipes 156 | 157 | If you're using Linux, common operations are provided in the included [justfile](justfile); install [just](https://github.com/casey/just) to use them, or refer to the justfile to run the commands manually if you prefer. `just` will use environment variables defined in `.env` if it exists. A [sample .env](.env-sample) is provided for reference. 158 | 159 | ## Running in Docker 160 | 161 | A [Dockerfile](Dockerfile) is provided for running gamatrix in a container. Build it with `just build`, then run it: 162 | 163 | **Linux/MacOS** 164 | 165 | `just run` 166 | 167 | **Windows** 168 | 169 | ```pwsh 170 | C:\Users\me> docker run --name gamatrix -p 8080:80/tcp -v C:\Users\me\dev\gamatrix-dbs:/usr/src/app/gog_dbs -v C:\Users\me\dev\gamatrix\.cache.json:/usr/src/app/.cache.json -v C:\Users\me\dev\gamatrix\myown-config.yaml:/usr/src/app/config/config.yaml gamatrix 171 | ``` 172 | 173 | Now you should be able to access the web page. If not, use `docker logs` to see what went wrong. The DBs are read on every call, so you can update them and they'll be used immediately. If you change the config file you'll need to restart the container for it to take effect. 174 | 175 | ### Restricting access 176 | 177 | Flask is not a production-grade web server, and some people may not like their GOG DBs being exposed to the open Internet (I'm not aware of anything more personal in there than user IDs and game info, but I have not exhaustively checked by any means, so better safe than sorry). If you want to make the service only accessible to your friends, you have a couple options. 178 | 179 | #### Allowed CIDRs 180 | 181 | You can add CIDRs to `allowed_cidrs` in the config file, as shown in the [sample config](config-sample.yaml). If this is used, any IP not in those CIDR blocks will get a 401 Unauthorized. If this is not defined or is empty, all IPs are allowed. 182 | 183 | #### iptables 184 | 185 | You can also block access with iptables. Network access is best handled at the network layer, not the application layer, so this is the more secure method, but more complicated. The example below is for Ubuntu 20.04, but should work for just about any Linux distribution. 186 | 187 | Create a new chain called gamatrix: 188 | 189 | ```pre 190 | # iptables -N gamatrix 191 | ``` 192 | 193 | Allow access to your friends' IPs, and your own internal network: 194 | 195 | ```pre 196 | # for cidr in 192.168.0.0/24 1.2.3.4 5.6.7.8; do iptables -A gamatrix --src $cidr -j ACCEPT ; done 197 | ``` 198 | 199 | Reject everyone else that tries to reach gamatrix (be sure to use the right port): 200 | 201 | ```pre 202 | # iptables -A gamatrix -m tcp -p tcp --dport 80 -j REJECT 203 | ``` 204 | 205 | Return to the calling chain if none of the rules applied: 206 | 207 | ```pre 208 | # iptables -A gamatrix -j RETURN 209 | ``` 210 | 211 | Finally, insert your new chain into the `DOCKER-USER` chain: 212 | 213 | ```pre 214 | # iptables -I DOCKER-USER 1 -j gamatrix 215 | ``` 216 | 217 | You final configuration should look like this: 218 | 219 | ```pre 220 | # iptables -L DOCKER-USER 221 | Chain DOCKER-USER (1 references) 222 | target prot opt source destination 223 | gamatrix all -- anywhere anywhere 224 | RETURN all -- anywhere anywhere 225 | 226 | # iptables -L gamatrix 227 | Chain gamatrix (1 references) 228 | target prot opt source destination 229 | ACCEPT all -- 192.168.0.0/24 anywhere 230 | ACCEPT all -- 1.2.3.4 anywhere 231 | ACCEPT all -- 5.6.7.8 anywhere 232 | REJECT tcp -- anywhere anywhere tcp dpt:http 233 | RETURN all -- anywhere anywhere 234 | ``` 235 | 236 | Save it so it persists after reboot: 237 | 238 | ```pre 239 | # apt install iptables-persistent 240 | # iptables-save > /etc/iptables/rules.v4 241 | ``` 242 | 243 | Now you can open the port on your router. For more information on using iptables with Docker, see [here](https://docs.docker.com/network/iptables/). 244 | 245 | ## Contributing 246 | 247 | PR's welcome! If you're making nontrivial changes, please include test output if possible. Update the version in [pyproject.toml](pyproject.toml) following [SemVer](https://semver.org/) conventions. 248 | 249 | ### Setting up your development environment 250 | 251 | If you do wish to contribute, here's a quickstart to get your development environment up and running: 252 | 253 | #### Quick Start 254 | 255 | ```bash 256 | git clone https://github.com/eniklas/gamatrix 257 | cd gamatrix 258 | python3 -m venv .venv 259 | 260 | . .venv/bin/activate # Linux/MacOS 261 | .venv/Scripts/Activate.ps1 # Windows 262 | ``` 263 | 264 | **Linux/MacOS** 265 | 266 | `just dev` 267 | 268 | Before you merge: 269 | 270 | `just bump-version` (by default this bumps the patch rev, add `minor` or `major` to bump those numbers) 271 | 272 | **Windows** 273 | 274 | ```pwsh 275 | python -m pip install -U pip 276 | python -m pip install -e .[dev] 277 | ``` 278 | 279 | #### Details 280 | 281 | 1. Install Python 3.7 or above. 282 | 1. Clone or fork the gamatrix repository. 283 | - Clone: `git clone https://github.com/eniklas/gamatrix` 284 | - Fork: `git clone https://github.com/my-github-username/gamatrix` 285 | 1. `cd gamatrix` 286 | 1. Set up a virtual environment for managing Python dependencies. 287 | - Linux/MacOS: `python3 -m venv .venv` 288 | - Windows: `py -3 -m venv .venv` 289 | 1. Activate your virtual environment 290 | - Linux/MacOS: `. .venv/bin/activate` 291 | - Windows: `.venv/bin/Activate.ps1` 292 | 1. Update the Python package manager: `python -m pip install -U pip` 293 | 1. Install the dependencies as well as the _development dependencies_ for the project: `python -m pip install -e .[dev]` 294 | 1. You are good to go. 295 | 296 | > Note: We prefer to use VSCode but as long as you keep formatting consistent to what our files currently have, you are good to use what you like. 297 | 298 | #### Extended Contributions: Packaging and a word about the Dockerfile 299 | 300 | We've also included the ability to create a `wheel` file for our project that you can 301 | use to distribute/make use of however you wish. Use the following commands after your 302 | development environment is set up to generate the `whl` package file. Note that the CI 303 | system (GitHub) will also generate the `whl` file you create with your GH Actions if you 304 | set them up. 305 | 306 | ```bash 307 | python -m pip install .[ci] # install the build tools 308 | python -m build --wheel # generate the dist/gamatrix-[ver]-none-any.whl file 309 | ``` 310 | 311 | For building the Docker image you will see that the [`Dockerfile`](./Dockerfile) generates 312 | a wheel, or `whl` package, installs it, and then removes the working folder containing the 313 | project sources. Be sure to keep that flow in mind when you update the project sources, in 314 | particular if you add more data folders to the content of the project. 315 | -------------------------------------------------------------------------------- /config-sample-windows.yaml: -------------------------------------------------------------------------------- 1 | # Path to a folder containing the GOG db files. 2 | db_path: C:\Users\my_user\Documents\gog_db 3 | 4 | # Valid values are info or debug 5 | log_level: info 6 | 7 | # If set to server, runs as a web service; otherwise runs as a CLI 8 | mode: server 9 | 10 | # The network interface to use when running in server mode 11 | interface: 0.0.0.0 12 | 13 | # The network port to use when running in server mode 14 | port: 80 15 | 16 | # If defined, IPs not in these CIDRs will get a 401 Unauthorized 17 | allowed_cidrs: 18 | # Even single IPs must be in CIDR format 19 | - 127.0.0.1/32 20 | - 192.168.0.0/24 21 | 22 | # IGDB client setup 23 | # Instructions for how to obtain a client ID & secret can be found at https://api-docs.igdb.com/#about 24 | igdb_client_id: 0123uvwxyz4567abcde89012fg34hi 25 | igdb_client_secret: abcdefghi01234jklmno56789pqrst 26 | 27 | # Path to a file to cache the IGDB setup from 28 | cache: C:\Users\my_user\Documents\gog-cache.json 29 | 30 | # The GOG user ID matching the result of 'select * from Users;' in the DB 31 | users: 32 | 12345: 33 | username: Bob 34 | db: bob-galaxy-2.0.db 35 | # Profile pic in static/profile_img/ dir 36 | pic: bob.png 37 | # The CIDRs the user's request can come from (public IP for external users) 38 | cidrs: 39 | - 127.0.0.1/32 40 | - 192.168.1.0/24 41 | 56789: 42 | username: Doug 43 | db: doug-galaxy-2.0.db 44 | pic: doug.png 45 | cidrs: 46 | - 1.2.3.4/32 47 | metadata: 48 | # Each title should match exactly as listed with --all-games; normal YAML quoting rules apply 49 | "7 Days to Die": 50 | # Each of these is optional 51 | max_players: no limit 52 | comment: I will die in less than 7 days 53 | # The title will link to this 54 | url: http://some-web-site.com 55 | "Broforce": 56 | max_players: 4 57 | comment: so many bros 58 | "Clue/Cluedo: The Classic Mystery Game": 59 | max_players: 6 60 | # The games that will be filtered out if "Include single-player games" is unchecked 61 | single_player: 62 | - "10 Second Ninja" 63 | - "1... 2... 3... KICK IT! (Drop That Beat Like an Ugly Baby)" 64 | # These games will always be filtered out 65 | hidden: 66 | - "ARK Editor" 67 | - "DARQ DLC" 68 | - "For Honor - Public Test" 69 | -------------------------------------------------------------------------------- /config-sample.yaml: -------------------------------------------------------------------------------- 1 | # This is the path to a folder containing the GOG db files. 2 | db_path: /path/to/gog/dbs 3 | 4 | # Valid values are info or debug 5 | log_level: info 6 | # If set to server, runs as a web service; otherwise runs as a CLI 7 | mode: server 8 | # The network interface to use when running in server mode 9 | interface: 0.0.0.0 10 | # The network port to use when running in server mode 11 | port: 8080 12 | 13 | # If defined, IPs not in these CIDRs will get a 401 Unauthorized 14 | allowed_cidrs: 15 | # Even single IPs must be in CIDR format 16 | - 127.0.0.1/32 17 | - 192.168.0.0/24 18 | 19 | # IGDB client setup 20 | # Instructions for how to obtain a client ID & secret can be found at https://api-docs.igdb.com/#about 21 | igdb_client_id: 0123uvwxyz4567abcde89012fg34hi 22 | igdb_client_secret: abcdefghi01234jklmno56789pqrst 23 | # File to cache the IGDB setup from 24 | cache: /path/to/cache/file 25 | 26 | # The GOG user ID matching the result of 'select * from Users;' in the DB 27 | users: 28 | 12345: 29 | username: Bob 30 | db: bob-galaxy-2.0.db 31 | # Profile pic in static/profile_img/ dir 32 | pic: bob.png 33 | # The CIDRs the user's request can come from (public IP for external users) 34 | cidrs: 35 | - 127.0.0.1/32 36 | - 192.168.1.0/24 37 | 56789: 38 | username: Doug 39 | db: doug-galaxy-2.0.db 40 | pic: doug.png 41 | cidrs: 42 | - 1.2.3.4/32 43 | metadata: 44 | # Each title should match exactly as listed with --all-games; normal YAML quoting rules apply 45 | "7 Days to Die": 46 | # Each of these is optional 47 | max_players: no limit 48 | comment: I will die in less than 7 days 49 | # The title will link to this 50 | url: http://some-web-site.com 51 | "Broforce": 52 | max_players: 4 53 | comment: so many bros 54 | "Clue/Cluedo: The Classic Mystery Game": 55 | max_players: 6 56 | # The games that will be filtered out if "Include single-player games" is unchecked 57 | single_player: 58 | - "10 Second Ninja" 59 | - "1... 2... 3... KICK IT! (Drop That Beat Like an Ugly Baby)" 60 | # These games will always be filtered out 61 | hidden: 62 | - "ARK Editor" 63 | - "DARQ DLC" 64 | - "For Honor - Public Test" 65 | -------------------------------------------------------------------------------- /doc/images/gamatrix-front-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/doc/images/gamatrix-front-page.png -------------------------------------------------------------------------------- /doc/images/gamatrix-game-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/doc/images/gamatrix-game-grid.png -------------------------------------------------------------------------------- /doc/images/gamatrix-game-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/doc/images/gamatrix-game-list.png -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load 2 | 3 | version := `grep "^version =" pyproject.toml |awk -F\" '{print $2}'` 4 | container_name := "gamatrix" 5 | 6 | # List recipes 7 | default: 8 | just --list 9 | 10 | # Increment version; pass in "major" or "minor" to bump those 11 | bump-version type="patch": 12 | #!/usr/bin/env bash 13 | set -euo pipefail 14 | old_version={{version}} 15 | IFS=. components=(${old_version##*-}) 16 | major=${components[0]} 17 | minor=${components[1]} 18 | patch=${components[2]} 19 | type={{type}} 20 | case $type in 21 | major|MAJOR) 22 | new_version="$((major+1)).0.0";; 23 | minor|MINOR) 24 | new_version="$major.$((minor+1)).0";; 25 | patch|PATCH) 26 | new_version="$major.$minor.$((patch+1))";; 27 | *) 28 | echo "Bad type: $type" 29 | echo "Valid types are major, minor, patch" 30 | exit 1;; 31 | esac 32 | echo "Bumping version from $old_version to $new_version" 33 | sed -i "s/^version =.*/version = \"$new_version\"/" pyproject.toml 34 | 35 | # Build the container 36 | build: 37 | docker build -t {{container_name}}:{{version}} -t {{container_name}}:latest . 38 | 39 | # Run the container 40 | run: 41 | #!/usr/bin/env bash 42 | PORT=${PROD_PORT:=80} 43 | TZ=${TZ:="America/Vancouver"} 44 | [ -z "$PROD_GOG_DBS" ] && echo "PROD_GOG_DBS env var must be set" && exit 1 45 | [ -z "$PROD_CACHE" ] && echo "PROD_CACHE env var must be set" && exit 1 46 | [ -z "$PROD_CONFIG" ] && echo "PROD_CONFIG env var must be set" && exit 1 47 | docker run \ 48 | -d \ 49 | --name {{container_name}} \ 50 | --dns 1.1.1.1 \ 51 | --dns 8.8.8.8 \ 52 | --log-driver=journald \ 53 | -p ${PORT}:${PORT}/tcp \ 54 | -e TZ="$TZ" \ 55 | -v ${PROD_GOG_DBS}:/usr/src/app/gog_dbs \ 56 | --mount type=bind,source=${PROD_CACHE},target=/usr/src/app/.cache.json \ 57 | --mount type=bind,source=${PROD_CONFIG},target=/usr/src/app/config.yaml,readonly \ 58 | -w /usr/src/app \ 59 | gamatrix \ 60 | sh -c "python -m gamatrix -c config.yaml -p 80 -s" 61 | 62 | # Tag commit with current release version 63 | git-tag: 64 | #!/usr/bin/env bash 65 | # Nonzero exit code means there are changes 66 | if [ ! "$(git diff --quiet --exit-code)" ]; then 67 | git commit -am "bump version" 68 | git tag --annotate --message="bump to version {{version}}" "{{version}}" 69 | git push 70 | git push --tags 71 | fi 72 | 73 | # Run the container in dev mode 74 | dev: 75 | #!/usr/bin/env bash 76 | set -eu -o pipefail 77 | 78 | # These env vars come from .env 79 | set_mounts() { 80 | if [ "${DEV_GOG_DBS}x" == "x" ]; then 81 | echo "WARNING: DEV_GOG_DBS not set in .env; DBs won't be available" 82 | db_mount="" 83 | else 84 | db_mount="-v ${DEV_GOG_DBS}:/usr/src/app/gog_dbs" 85 | fi 86 | 87 | if [ "${DEV_CONFIG}x" == "x" ]; then 88 | echo "WARNING: DEV_CONFIG not set in .env; config won't be available" 89 | config_mount="" 90 | else 91 | config_mount="-v ${DEV_CONFIG}:/usr/src/app/config.yaml" 92 | fi 93 | 94 | if [ "${DEV_CACHE}x" == "x" ]; then 95 | echo "WARNING: DEV_CACHE not set in .env; cache won't be available" 96 | cache_mount="" 97 | else 98 | cache_mount="-v ${DEV_CACHE}:/usr/src/app/.cache.json" 99 | fi 100 | 101 | # This allows the user to set their own aliases, set -o vi, etc. 102 | bashrc_user_mount="" 103 | if [ "${BASHRC_USER}x" != "x" ] && [ -e "$BASHRC_USER" ]; then 104 | bashrc_user_mount="-v ${BASHRC_USER}:/root/.bashrc.user" 105 | fi 106 | } 107 | 108 | cleanup() { 109 | echo "Removing old docker containers. Names will appear upon success:" 110 | set +e 111 | # This will rm itself 112 | docker stop $CONTAINER_NAME 113 | set -e 114 | } 115 | 116 | # Default to latest if env var is not set 117 | CONTAINER_VERSION=${CONTAINER_VERSION:=latest} 118 | CONTAINER_NAME={{container_name}}-dev 119 | CONTAINER_IMAGE={{container_name}}:${CONTAINER_VERSION} 120 | # Default to 8080 if not set in .env 121 | PORT=${DEV_PORT:=8080} 122 | 123 | echo "Container image: ${CONTAINER_IMAGE}" 124 | 125 | # Stop any accidental running copies of the build container 126 | cleanup 127 | set_mounts 128 | 129 | # Ensure we make it to cleanup even if there's a failure from this point 130 | set +e 131 | 132 | docker run --rm -d -t \ 133 | --name=${CONTAINER_NAME} \ 134 | -p ${PORT}:${PORT} \ 135 | -v $(pwd):/usr/src/app \ 136 | -v /var/run/docker.sock:/var/run/docker.sock \ 137 | $bashrc_user_mount \ 138 | $db_mount \ 139 | $config_mount \ 140 | $cache_mount \ 141 | -w /usr/src/app \ 142 | ${CONTAINER_IMAGE} \ 143 | /bin/bash 144 | 145 | # Install gamatrix in editable mode 146 | docker exec -d \ 147 | -w /usr/src/app \ 148 | ${CONTAINER_NAME} \ 149 | sh -c "python -m pip install -e .[dev]" 150 | 151 | # Launch container 152 | docker exec -it \ 153 | -w /usr/src/app \ 154 | ${CONTAINER_NAME} \ 155 | /bin/bash 156 | 157 | cleanup 158 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gamatrix" 3 | # Changing this requires rebuilding even if in editable mode 4 | version = "1.4.7" 5 | authors = [ 6 | {name = "Erik Niklas", email = "github@bobanddoug.com"}, 7 | {name = "Derek Keeler", email = "34773432+derek-keeler@users.noreply.github.com"}, 8 | {name = "Klosteinmann", email = "34807323+klosteinmann@users.noreply.github.com"}, 9 | ] 10 | description = """A tool to compare the games owned by several users, and list 11 | all the games they have in common. It requires all users to 12 | use [GOG Galaxy](https://www.gog.com/galaxy); since GOG Galaxy 13 | supports almost all major digital distribution platforms.""" 14 | readme = "README.md" 15 | requires-python = ">=3.7" 16 | license = { file = "LICENSE" } 17 | 18 | classifiers = [ 19 | "Development Status :: 3 - Alpha", 20 | "License :: OSI Approved :: MIT License", 21 | "Natural Language :: English", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | ] 30 | dependencies = [ 31 | "docopt==0.6.2", 32 | "Flask==3.1.0", 33 | "Jinja2==3.1.5", 34 | "pyyaml==6.0.2", 35 | "requests==2.32.3", 36 | ] 37 | 38 | # Optional dependencies for specific workflows. Install these with 39 | # the command line `pip install .[name-of-optional-dependency]`. 40 | [project.optional-dependencies] 41 | # Dependencies for local developement. 42 | dev = [ 43 | "black", 44 | "flake8", 45 | "mypy", 46 | "pytest", 47 | "pytest-cov", 48 | "types-docopt", 49 | "types-PyYAML", 50 | "types-requests", 51 | "types-setuptools", 52 | ] 53 | # Dependencies for CI/CD automation system. 54 | ci = [ 55 | "build", 56 | "wheel", 57 | ] 58 | 59 | [tool.mypy] 60 | files = "src" 61 | ignore_missing_imports = true 62 | 63 | [tool.pytest.ini_options] 64 | addopts = "--cov=gamatrix --cov-branch" 65 | pythonpath = ["src"] 66 | 67 | [tool.setuptools.package-data] 68 | "gamatrix.templates" = ["*.jinja"] 69 | "gamatrix.static" = ["*.png", "*.jpg"] 70 | "gamatrix.static.profile_img" = ["*.png", "*.jpg"] -------------------------------------------------------------------------------- /src/gamatrix/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/__init__.py -------------------------------------------------------------------------------- /src/gamatrix/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | gamatrix 4 | Show and compare between games owned by multiple users. 5 | 6 | Usage: 7 | gamatrix --help 8 | gamatrix --version 9 | gamatrix [--config-file=CFG] [--debug] [--all-games] [--interface=IFC] [--installed-only] [--include-single-player] [--port=PORT] [--server] [--update-cache] [--userid=UID ...] 10 | 11 | Options: 12 | -h, --help Show this help message and exit. 13 | -v, --version Print version and exit. 14 | -c CFG, --config-file=CFG The config file to use. 15 | -d, --debug Print out verbose debug output. 16 | -a, --all-games List all games owned by the selected users (doesn't include single player unless -S is used). 17 | -i IFC, --interface=IFC The network interface to use if running in server mode; default is 0.0.0.0. 18 | -I, --installed-only Only show games installed by all users. 19 | -p PORT, --port=PORT The network port to use if running in server mode; default is 8080. 20 | -s, --server Run in server mode. 21 | -S, --include-single-player Include single player games. 22 | -U, --update-cache Update cache entries that have incomplete info. 23 | -u USERID, --userid=USERID The GOG user IDs to compare, there can be multiples of this switch. 24 | """ 25 | 26 | import docopt 27 | import logging 28 | import os 29 | import pkg_resources 30 | import random 31 | import sys 32 | import time 33 | 34 | from flask import Flask, render_template, request 35 | from ipaddress import IPv4Address, IPv4Network 36 | import yaml 37 | from typing import Any, Dict, List 38 | from werkzeug.utils import secure_filename 39 | 40 | import gamatrix.helpers.constants as constants 41 | from gamatrix.helpers.cache_helper import Cache 42 | from gamatrix.helpers.gogdb_helper import gogDB, is_sqlite3 43 | from gamatrix.helpers.igdb_helper import IGDBHelper 44 | from gamatrix.helpers.misc_helper import get_slug_from_title 45 | from gamatrix.helpers.network_helper import check_ip_is_authorized 46 | 47 | app = Flask(__name__) 48 | 49 | 50 | @app.route("/") 51 | def root(): 52 | check_ip_is_authorized(request.remote_addr, config["allowed_cidrs"]) 53 | 54 | return render_template( 55 | "index.html.jinja", 56 | users=config["users"], 57 | uploads_enabled=config["uploads_enabled"], 58 | platforms=constants.PLATFORMS, 59 | version=version, 60 | ) 61 | 62 | 63 | # https://flask.palletsprojects.com/en/2.0.x/patterns/fileuploads/ 64 | @app.route("/upload", methods=["GET", "POST"]) 65 | def upload_file(): 66 | check_ip_is_authorized(request.remote_addr, config["allowed_cidrs"]) 67 | 68 | if request.method == "POST": 69 | message = "Upload failed: " 70 | 71 | # Check if the post request has the file part 72 | if "file" not in request.files: 73 | message += "no file part in post request" 74 | else: 75 | # Until we use a prod server, files that are too large will just hang :-( 76 | # See the flask site above for deets 77 | file = request.files["file"] 78 | 79 | # If user does not select file, the browser submits an empty part without filename 80 | if file.filename == "": 81 | message += "no file selected" 82 | elif not allowed_file(file.filename): 83 | message += "unsupported file extension" 84 | else: 85 | # Name the file according to who uploaded it 86 | user, target_filename = get_db_name_from_ip(request.remote_addr) 87 | if target_filename is None: 88 | message += "failed to determine target filename from your IP; is it in the config file?" 89 | elif not is_sqlite3(file.read(16)): 90 | message += "file is not an SQLite database" 91 | else: 92 | log.info(f"Uploading {target_filename} from {request.remote_addr}") 93 | filename = secure_filename(target_filename) 94 | 95 | # Back up the previous file 96 | full_path = os.path.join(app.config["UPLOAD_FOLDER"], filename) 97 | backup_filename = f"{filename}.bak" 98 | full_backup_path = os.path.join( 99 | app.config["UPLOAD_FOLDER"], backup_filename 100 | ) 101 | 102 | if os.path.exists(full_path): 103 | os.replace(full_path, full_backup_path) 104 | 105 | # Put the cursor back to the start after the above file.read() 106 | file.seek(0) 107 | file.save(full_path) 108 | # Could call get_db_mtime() here but this is less expensive 109 | config["users"][user]["db_mtime"] = time.strftime( 110 | constants.TIME_FORMAT, time.localtime() 111 | ) 112 | message = f"Great success! File uploaded as {filename}" 113 | 114 | return render_template("upload_status.html.jinja", message=message) 115 | else: 116 | return """ 117 | 118 | Upload DB 119 |

Upload DB

120 | GOG DBs are usually in C:\\ProgramData\\GOG.com\\Galaxy\\storage\\galaxy-2.0.db 121 |

122 |
123 | 124 | 125 |
126 | """ 127 | 128 | 129 | @app.route("/compare", methods=["GET", "POST"]) 130 | def compare_libraries(): 131 | check_ip_is_authorized(request.remote_addr, config["allowed_cidrs"]) 132 | 133 | if request.args["option"] == "upload": 134 | return upload_file() 135 | 136 | opts = init_opts() 137 | 138 | # Check boxes get passed in as "on" if checked, or not at all if unchecked 139 | for k in request.args.keys(): 140 | # Only user IDs are ints 141 | try: 142 | i = int(k) 143 | opts["user_ids_to_compare"][i] = config["users"][i] 144 | except ValueError: 145 | if k.startswith("exclude_platform_"): 146 | opts["exclude_platforms"].append(k.split("_")[-1]) 147 | else: 148 | opts[k] = True 149 | 150 | # If no users were selected, just refresh the page 151 | if not opts["user_ids_to_compare"]: 152 | return root() 153 | 154 | gog = gogDB(config, opts) 155 | 156 | if request.args["option"] == "grid": 157 | gog.config["all_games"] = True 158 | template = "game_grid.html.jinja" 159 | elif request.args["option"] == "list": 160 | template = "game_list.html.jinja" 161 | else: 162 | return root() 163 | 164 | common_games = gog.get_common_games() 165 | 166 | if not igdb.access_token: 167 | igdb.get_access_token() 168 | 169 | for k in list(common_games.keys()): 170 | log.debug(f'{k}: using igdb_key {common_games[k]["igdb_key"]}') 171 | # Get the IGDB ID by release key if possible, otherwise try by title 172 | igdb.get_igdb_id(common_games[k]["igdb_key"]) or igdb.get_igdb_id_by_slug( 173 | common_games[k]["igdb_key"], 174 | common_games[k]["slug"], 175 | config["update_cache"], 176 | ) # type: ignore 177 | igdb.get_game_info(common_games[k]["igdb_key"]) 178 | igdb.get_multiplayer_info(common_games[k]["igdb_key"]) 179 | 180 | cache.save() 181 | set_multiplayer_status(common_games, cache.data) 182 | common_games = gog.merge_duplicate_titles(common_games) 183 | 184 | common_games = gog.filter_games(common_games, gog.config["all_games"]) 185 | num_games = len(common_games) 186 | 187 | log.debug(f'user_ids_to_compare = {opts["user_ids_to_compare"]}') 188 | 189 | if opts["randomize"]: 190 | key = random.choice(list(common_games)) 191 | log.debug(f"Chose random release key {key}") 192 | common_games = {key: common_games[key]} 193 | 194 | debug_str = "" 195 | return render_template( 196 | template, 197 | debug_str=debug_str, 198 | games=common_games, 199 | users=opts["user_ids_to_compare"], 200 | caption=gog.get_caption(num_games, opts["randomize"]), 201 | show_keys=opts["show_keys"], 202 | randomize=opts["randomize"], 203 | platforms=constants.PLATFORMS, 204 | ) 205 | 206 | 207 | def get_db_name_from_ip(ip): 208 | """Returns the userid and DB filename based on the IP of the user""" 209 | ip = IPv4Address(ip) 210 | 211 | for user in config["users"]: 212 | if "cidrs" in config["users"][user]: 213 | for cidr in config["users"][user]["cidrs"]: 214 | if ip in cidr: 215 | return user, config["users"][user]["db"] 216 | 217 | return None, None 218 | 219 | 220 | def allowed_file(filename): 221 | """Returns True if filename has an allowed extension""" 222 | return ( 223 | "." in filename 224 | and filename.rsplit(".", 1)[1].lower() in constants.UPLOAD_ALLOWED_EXTENSIONS 225 | ) 226 | 227 | 228 | def init_opts(): 229 | """Initializes the options to pass to the gogDB class. Since the 230 | config is only read once, we need to be able to reinit any options 231 | that can be passed from the web UI 232 | """ 233 | 234 | return { 235 | "include_single_player": False, 236 | "exclusive": False, 237 | "show_keys": False, 238 | "randomize": False, 239 | "user_ids_to_compare": {}, 240 | "exclude_platforms": [], 241 | } 242 | 243 | 244 | def get_db_mtime(db): 245 | """Returns the modification time of DB in local time""" 246 | try: 247 | mtime = time.strftime( 248 | constants.TIME_FORMAT, time.localtime(os.path.getmtime(db)) 249 | ) 250 | except Exception: 251 | mtime = "unavailable" 252 | return mtime 253 | 254 | 255 | def build_config(args: Dict[str, Any]) -> Dict[str, Any]: 256 | """Returns a config dict created from the config file and 257 | command-line arguments, with the latter taking precedence 258 | """ 259 | config_file = args.get("--config-file", None) 260 | if config_file is not None: 261 | with open(config_file, "r") as config_file: 262 | config = yaml.safe_load(config_file) 263 | else: 264 | # We didn't get a config file, so populate from args 265 | config = {} 266 | 267 | # TODO: allow using user IDs 268 | # TODO: should be able to use unambiguous partial names 269 | if "users" not in config: 270 | raise ValueError("You must use -u or have users in the config file") 271 | 272 | # Command-line args override values in the config file 273 | 274 | # This can't be given as an argument as it wouldn't make much sense; 275 | # provide a sane default if it's missing from the config file 276 | if "db_path" not in config: 277 | config["db_path"] = "." 278 | 279 | config["all_games"] = args.get("--all-games", False) 280 | config["include_single_player"] = args.get("--include-single-player", False) 281 | config["installed_only"] = args.get("--installed-only", False) 282 | 283 | if args.get( 284 | "--server", False 285 | ): # Note that the --server opt is False unless present 286 | config["mode"] = "server" 287 | 288 | if args.get("--interface"): 289 | config["interface"] = args["--interface"] 290 | if "interface" not in config: 291 | config["interface"] = "0.0.0.0" 292 | 293 | if args.get("--port"): 294 | config["port"] = int(args["--port"]) 295 | if "port" not in config: 296 | config["port"] = 8080 297 | 298 | # Convert allowed CIDRs into IPv4Network objects 299 | cidrs = [] 300 | if "allowed_cidrs" in config: 301 | for cidr in config["allowed_cidrs"]: 302 | cidrs.append(IPv4Network(cidr)) 303 | config["allowed_cidrs"] = cidrs 304 | 305 | # DBs and user IDs can be in the config file and/or passed in as args 306 | config["db_list"] = [] 307 | if "users" not in config: 308 | config["users"] = {} 309 | 310 | for userid in config["users"]: 311 | full_db_path = f'{config["db_path"]}/{config["users"][userid]["db"]}' 312 | config["db_list"].append(full_db_path) 313 | config["users"][userid]["db_mtime"] = get_db_mtime(full_db_path) 314 | 315 | # Convert CIDRs into IPv4Network objects; if there are none, disable uploads 316 | config["uploads_enabled"] = False 317 | if "cidrs" in config["users"][userid]: 318 | for i in range(len(config["users"][userid]["cidrs"])): 319 | config["users"][userid]["cidrs"][i] = IPv4Network( 320 | config["users"][userid]["cidrs"][i] 321 | ) 322 | config["uploads_enabled"] = True 323 | 324 | for userid_str in args.get("--userid", []): 325 | userid = int(userid_str) 326 | if userid not in config["users"]: 327 | raise ValueError(f"User ID {userid} isn't defined in the config file") 328 | elif "db" not in config["users"][userid]: 329 | raise ValueError( 330 | f"User ID {userid} is missing the db key in the config file" 331 | ) 332 | elif ( 333 | f'{config["db_path"]}/{config["users"][userid]["db"]}' 334 | not in config["db_list"] 335 | ): 336 | config["db_list"].append( 337 | f'{config["db_path"]}/{config["users"][userid]["db"]}' 338 | ) 339 | 340 | # Order users by username to avoid having to do it in the templates 341 | config["users"] = { 342 | k: v 343 | for k, v in sorted( 344 | config["users"].items(), key=lambda item: item[1]["username"].lower() 345 | ) 346 | } 347 | 348 | if "hidden" not in config: 349 | config["hidden"] = [] 350 | 351 | config["update_cache"] = args.get("--update-cache", False) 352 | 353 | # Lowercase and remove non-alphanumeric characters for better matching 354 | for i in range(len(config["hidden"])): 355 | config["hidden"][i] = get_slug_from_title(config["hidden"][i]) 356 | 357 | slug_metadata = {} 358 | for title in config["metadata"]: 359 | slug = get_slug_from_title(title) 360 | slug_metadata[slug] = config["metadata"][title] 361 | 362 | config["metadata"] = slug_metadata 363 | 364 | return config 365 | 366 | 367 | def set_multiplayer_status(game_list, cache): 368 | """ 369 | Sets the max_players for each release key; precedence is: 370 | - max_players in the config yaml 371 | - max_players from IGDB 372 | - 1 if the above aren't available and the only game mode from IGDB is single player 373 | - 0 (unknown) otherwise 374 | Also sets multiplayer to True if any of the of the following are true: 375 | - max_players > 1 376 | - IGDB game modes includes a multiplayer mode 377 | """ 378 | for k in game_list: 379 | igdb_key = game_list[k]["igdb_key"] 380 | max_players = 0 381 | multiplayer = False 382 | reason = "as we have no max player info and can't infer from game modes" 383 | 384 | if "max_players" in game_list[k]: 385 | max_players = game_list[k]["max_players"] 386 | reason = "from config file" 387 | multiplayer = max_players > 1 388 | 389 | elif igdb_key not in cache["igdb"]["games"]: 390 | reason = ( 391 | f"no IGDB info in cache for {igdb_key}, did you call get_igdb_id()?" 392 | ) 393 | 394 | elif "max_players" not in cache["igdb"]["games"][igdb_key]: 395 | reason = f"IGDB {igdb_key} max_players not found, did you call get_multiplayer_info()?" 396 | log.warning(f"{k}: something seems wrong, see next message") 397 | 398 | elif cache["igdb"]["games"][igdb_key]["max_players"] > 0: 399 | max_players = cache["igdb"]["games"][igdb_key]["max_players"] 400 | reason = "from IGDB cache" 401 | multiplayer = cache["igdb"]["games"][igdb_key]["max_players"] > 1 402 | 403 | # We don't have max player info, so try to infer it from game modes 404 | elif ( 405 | "info" in cache["igdb"]["games"][igdb_key] 406 | and cache["igdb"]["games"][igdb_key]["info"] 407 | and "game_modes" in cache["igdb"]["games"][igdb_key]["info"][0] 408 | ): 409 | if cache["igdb"]["games"][igdb_key]["info"][0]["game_modes"] == [ 410 | constants.IGDB_GAME_MODE["singleplayer"] 411 | ]: 412 | max_players = 1 413 | reason = "as IGDB has single player as the only game mode" 414 | else: 415 | for mode in cache["igdb"]["games"][igdb_key]["info"][0]["game_modes"]: 416 | if mode in constants.IGDB_MULTIPLAYER_GAME_MODES: 417 | multiplayer = True 418 | reason = f"as game modes includes {mode}" 419 | break 420 | 421 | log.debug( 422 | f"{k} ({game_list[k]['title']}, IGDB key {igdb_key}): " 423 | f"multiplayer {multiplayer}, max players {max_players} {reason}" 424 | ) 425 | game_list[k]["multiplayer"] = multiplayer 426 | game_list[k]["max_players"] = max_players 427 | 428 | 429 | def parse_cmdline(argv: List[str], docstr: str, version: str) -> Dict[str, Any]: 430 | """Get the docopt stuff out of the way because ugly.""" 431 | return docopt.docopt( 432 | docstr, 433 | argv=argv, 434 | help=True, 435 | version=version, 436 | options_first=True, 437 | ) 438 | 439 | 440 | if __name__ == "__main__": 441 | logging.basicConfig( 442 | format="%(asctime)s %(levelname)s %(name)s %(message)s", 443 | level=logging.INFO, 444 | datefmt="%Y-%m-%d %H:%M:%S", 445 | ) 446 | log = logging.getLogger() 447 | 448 | version = pkg_resources.get_distribution("gamatrix").version 449 | 450 | opts = parse_cmdline( 451 | argv=sys.argv[1:], 452 | docstr=__doc__ if __doc__ is not None else "", 453 | version=version, 454 | ) 455 | 456 | if opts.get("--debug", False): 457 | log.setLevel(logging.DEBUG) 458 | 459 | log.debug(f"Command line arguments: {sys.argv}") 460 | log.debug(f"Arguments after parsing: {opts}") 461 | 462 | config = build_config(opts) 463 | log.debug(f"config = {config}") 464 | 465 | cache = Cache(config["cache"]) 466 | # Get multiplayer info from IGDB and save it to the cache 467 | igdb = IGDBHelper( 468 | config["igdb_client_id"], config["igdb_client_secret"], cache.data 469 | ) 470 | 471 | if "mode" in config and config["mode"] == "server": 472 | # Start Flask to run in server mode until killed 473 | if os.name != "nt": 474 | time.tzset() # type: ignore 475 | 476 | app.config["UPLOAD_FOLDER"] = config["db_path"] 477 | app.config["MAX_CONTENT_LENGTH"] = constants.UPLOAD_MAX_SIZE 478 | app.run(host=config["interface"], port=config["port"]) 479 | sys.exit(0) 480 | 481 | user_ids_to_compare = opts.get("--userid", []) 482 | if user_ids_to_compare: 483 | user_ids_to_compare = [int(u) for u in user_ids_to_compare] 484 | else: 485 | user_ids_to_compare = [u for u in config["users"].keys()] 486 | 487 | # init_opts() is meant for server mode; any CLI options that are also 488 | # web UI options need to be overridden 489 | web_opts = init_opts() 490 | web_opts["include_single_player"] = opts.get("--include-single-player", False) 491 | 492 | for userid in user_ids_to_compare: 493 | web_opts["user_ids_to_compare"][userid] = config["users"][userid] 494 | 495 | log.debug(f'user_ids_to_compare = {web_opts["user_ids_to_compare"]}') 496 | 497 | gog = gogDB(config, web_opts) 498 | common_games = gog.get_common_games() 499 | 500 | for k in list(common_games.keys()): 501 | log.debug(f'{k}: using igdb_key {common_games[k]["igdb_key"]}') 502 | # Get the IGDB ID by release key if possible, otherwise try by title 503 | igdb.get_igdb_id( 504 | common_games[k]["igdb_key"], config["update_cache"] 505 | ) or igdb.get_igdb_id_by_slug( 506 | common_games[k]["igdb_key"], 507 | common_games[k]["slug"], 508 | config["update_cache"], 509 | ) # type: ignore 510 | igdb.get_game_info(common_games[k]["igdb_key"], config["update_cache"]) 511 | igdb.get_multiplayer_info(common_games[k]["igdb_key"], config["update_cache"]) 512 | 513 | cache.save() 514 | set_multiplayer_status(common_games, cache.data) 515 | common_games = gog.merge_duplicate_titles(common_games) 516 | 517 | common_games = gog.filter_games(common_games, config["all_games"]) 518 | 519 | for key in common_games: 520 | usernames_with_game_installed = [ 521 | config["users"][userid]["username"] 522 | for userid in common_games[key]["installed"] 523 | ] 524 | 525 | print( 526 | "{} ({})".format( 527 | common_games[key]["title"], 528 | ", ".join(common_games[key]["platforms"]), 529 | ), 530 | end="", 531 | ) 532 | if "max_players" in common_games[key]: 533 | print(f' Players: {common_games[key]["max_players"]}', end="") 534 | if "comment" in common_games[key]: 535 | print(f' Comment: {common_games[key]["comment"]}', end="") 536 | if not usernames_with_game_installed: 537 | print(" Installed: (none)") 538 | else: 539 | print(f' Installed: {", ".join(usernames_with_game_installed)}') 540 | 541 | print(gog.get_caption(len(common_games))) 542 | -------------------------------------------------------------------------------- /src/gamatrix/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/helpers/__init__.py -------------------------------------------------------------------------------- /src/gamatrix/helpers/cache_helper.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | 5 | 6 | class Cache: 7 | def __init__(self, cache_file): 8 | self.cache_file = cache_file 9 | self.data = {} 10 | self.log = logging.getLogger(__name__) 11 | 12 | if os.path.exists(self.cache_file): 13 | self.log.debug(f"Reading cache file {self.cache_file}") 14 | with open(self.cache_file, "r") as f: 15 | self.data = json.load(f) 16 | 17 | else: 18 | self.log.warning( 19 | f"Cache file {self.cache_file} not found, making new cache" 20 | ) 21 | 22 | self.data["dirty"] = False 23 | 24 | def save(self): 25 | if self.data["dirty"]: 26 | self.log.debug("Cache is dirty, saving") 27 | with open(self.cache_file, "w") as f: 28 | json.dump(self.data, f) 29 | else: 30 | self.log.debug("Cache is clean, not saving") 31 | -------------------------------------------------------------------------------- /src/gamatrix/helpers/constants.py: -------------------------------------------------------------------------------- 1 | # Allowed extensions for uploaded files 2 | UPLOAD_ALLOWED_EXTENSIONS = ["db"] 3 | UPLOAD_MAX_SIZE = 300 * 1024 * 1024 4 | 5 | # Full mapping is at https://api-docs.igdb.com/#external-game-enums, 6 | # but only Steam and GOG actually work; the other platforms' IDs 7 | # don't match what's in IGDB 8 | IGDB_PLATFORM_ID = { 9 | "steam": 1, 10 | "gog": 5, 11 | } 12 | # The mapping of game modes from https://api-docs.igdb.com/#game-mode 13 | IGDB_GAME_MODE = { 14 | "singleplayer": 1, 15 | "multiplayer": 2, 16 | "coop": 3, 17 | "splitscreen": 4, 18 | "mmo": 5, 19 | "battleroyale": 6, 20 | } 21 | # The modes from IGDB_GAME_MODE that we consider multiplayer 22 | IGDB_MULTIPLAYER_GAME_MODES = [ 23 | IGDB_GAME_MODE["multiplayer"], 24 | IGDB_GAME_MODE["mmo"], 25 | IGDB_GAME_MODE["battleroyale"], 26 | ] 27 | IGDB_MAX_PLAYER_KEYS = [ 28 | "offlinecoopmax", 29 | "offlinemax", 30 | "onlinecoopmax", 31 | "onlinemax", 32 | ] 33 | # The API has a rate limit of 4 requests/sec 34 | IGDB_API_CALL_DELAY = 0.25 35 | 36 | # Order matters; when deduping, the release key 37 | # retained will be the first one in the list 38 | PLATFORMS = ( 39 | "steam", 40 | "gog", 41 | "battlenet", 42 | "bethesda", 43 | "epic", 44 | "origin", 45 | "uplay", 46 | "xboxone", 47 | ) 48 | 49 | # e.g. Sat Oct 31 2020 10:32:10 PDT 50 | TIME_FORMAT = "%a %b %d %Y %H:%M:%S %Z" 51 | -------------------------------------------------------------------------------- /src/gamatrix/helpers/gogdb_helper.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | import logging 4 | import os 5 | import sqlite3 6 | from functools import cmp_to_key 7 | 8 | from gamatrix.helpers.misc_helper import get_slug_from_title 9 | 10 | from gamatrix.helpers.constants import PLATFORMS 11 | 12 | 13 | def is_sqlite3(stream: bytearray) -> bool: 14 | """Returns True if stream contains an SQLite3 DB header""" 15 | # https://www.sqlite.org/fileformat.html 16 | return len(stream) >= 16 and stream[:16] == b"SQLite format 3\000" 17 | 18 | 19 | class gogDB: 20 | def __init__( 21 | self, 22 | config, 23 | opts, 24 | ): 25 | # Server mode only reads the config once, so we don't want to modify it 26 | self.config = copy.deepcopy(config) 27 | for k in opts: 28 | self.config[k] = opts[k] 29 | self.config["user_ids_to_exclude"] = [] 30 | 31 | # All DBs defined in the config file will be in db_list. Remove the DBs for 32 | # users that we don't want to compare, unless exclusive was specified, in 33 | # which case we need to look at all DBs 34 | for user in list(self.config["users"]): 35 | if user not in self.config["user_ids_to_compare"]: 36 | if self.config["exclusive"]: 37 | self.config["user_ids_to_exclude"].append(user) 38 | elif ( 39 | "db" in self.config["users"][user] 40 | and "{}/{}".format( 41 | self.config["db_path"], self.config["users"][user]["db"] 42 | ) 43 | in self.config["db_list"] 44 | ): 45 | self.config["db_list"].remove( 46 | "{}/{}".format( 47 | self.config["db_path"], self.config["users"][user]["db"] 48 | ) 49 | ) 50 | 51 | self.log = logging.getLogger(__name__) 52 | self.log.debug("db_list = {}".format(self.config["db_list"])) 53 | 54 | def use_db(self, db): 55 | if not os.path.exists(db): 56 | raise FileNotFoundError(f"DB {db} doesn't exist") 57 | 58 | self.db = db 59 | self.conn = sqlite3.connect(db) 60 | self.cursor = self.conn.cursor() 61 | 62 | def close_connection(self): 63 | self.conn.close() 64 | 65 | def get_user(self): 66 | user_query = self.cursor.execute("select * from Users") 67 | if user_query.rowcount == 0: 68 | raise ValueError("No users found in the Users table in the DB") 69 | 70 | user = self.cursor.fetchall()[0] 71 | 72 | if user_query.rowcount > 1: 73 | self.log.warning( 74 | "Found multiple users in the DB; using the first one ({})".format(user) 75 | ) 76 | 77 | return user 78 | 79 | def get_gamepiecetype_id(self, name): 80 | """Returns the numeric ID for the specified type""" 81 | return self.cursor.execute( 82 | 'SELECT id FROM GamePieceTypes WHERE type="{}"'.format(name) 83 | ).fetchone()[0] 84 | 85 | # Taken from https://github.com/AB1908/GOG-Galaxy-Export-Script/blob/master/galaxy_library_export.py 86 | def get_owned_games(self): 87 | """Returns a list of release keys owned per the current DB""" 88 | 89 | owned_game_database = """CREATE TEMP VIEW MasterList AS 90 | SELECT GamePieces.releaseKey, GamePieces.gamePieceTypeId, GamePieces.value FROM ProductPurchaseDates 91 | JOIN GamePieces ON ProductPurchaseDates.gameReleaseKey = GamePieces.releaseKey;""" 92 | og_fields = [ 93 | """CREATE TEMP VIEW MasterDB AS SELECT DISTINCT(MasterList.releaseKey) AS releaseKey, MasterList.value AS title, PLATFORMS.value AS platformList""" 94 | ] 95 | og_references = [""" FROM MasterList, MasterList AS PLATFORMS"""] 96 | og_conditions = [ 97 | """ WHERE ((MasterList.gamePieceTypeId={}) OR (MasterList.gamePieceTypeId={})) AND ((PLATFORMS.releaseKey=MasterList.releaseKey) AND (PLATFORMS.gamePieceTypeId={}))""".format( 98 | self.get_gamepiecetype_id("originalTitle"), 99 | self.get_gamepiecetype_id("title"), 100 | self.get_gamepiecetype_id("allGameReleases"), 101 | ) 102 | ] 103 | og_order = """ ORDER BY title;""" 104 | og_resultFields = [ 105 | "GROUP_CONCAT(DISTINCT MasterDB.releaseKey)", 106 | "MasterDB.title", 107 | ] 108 | og_resultGroupBy = ["MasterDB.platformList"] 109 | og_query = "".join(og_fields + og_references + og_conditions) + og_order 110 | 111 | # Display each game and its details along with corresponding release key grouped by releasesList 112 | unique_game_data = ( 113 | """SELECT {} FROM MasterDB GROUP BY {} ORDER BY MasterDB.title;""".format( 114 | ", ".join(og_resultFields), ", ".join(og_resultGroupBy) 115 | ) 116 | ) 117 | 118 | for query in [owned_game_database, og_query, unique_game_data]: 119 | self.log.debug("Running query: {}".format(query)) 120 | self.cursor.execute(query) 121 | 122 | return self.cursor.fetchall() 123 | 124 | def get_igdb_release_key(self, gamepiecetype_id, release_key): 125 | """ 126 | Returns the release key to look up in IGDB. Steam keys are the 127 | most reliable to look up; GOG keys are about 50% reliable; 128 | other platforms will never work. So, our order of preference is: 129 | - Steam 130 | - GOG 131 | - release_key 132 | """ 133 | query = f'SELECT * FROM GamePieces WHERE releaseKey="{release_key}" and gamePieceTypeId = {gamepiecetype_id}' 134 | self.log.debug("Running query: {}".format(query)) 135 | self.cursor.execute(query) 136 | 137 | raw_result = self.cursor.fetchall() 138 | self.log.debug(f"raw_result = {raw_result}") 139 | result = json.loads(raw_result[0][3]) 140 | self.log.debug(f"{release_key}: all release keys: {result}") 141 | if "releases" not in result: 142 | self.log.debug( 143 | f'{release_key}: "releases" not found in result for release keys' 144 | ) 145 | return release_key 146 | 147 | for k in result["releases"]: 148 | # Sometimes there's a steam_1234 and steam_steam_1234, but always in that order 149 | if k.startswith("steam_") and not k.startswith("steam_steam_"): 150 | return k 151 | 152 | # If we found no Steam key, look for a GOG key 153 | for k in result["releases"]: 154 | if k.startswith("gog_"): 155 | return k 156 | 157 | # If we found neither Steam nor GOG keys, just return the key we were given 158 | return release_key 159 | 160 | def get_installed_games(self): 161 | """Returns a list of release keys installed per the current DB""" 162 | 163 | # https://www.reddit.com/r/gog/comments/ek3vtz/dev_gog_galaxy_20_get_list_of_gameid_of_installed/ 164 | query = """SELECT trim(GamePieces.releaseKey) FROM GamePieces 165 | JOIN GamePieceTypes ON GamePieces.gamePieceTypeId = GamePieceTypes.id 166 | WHERE releaseKey IN 167 | (SELECT platforms.name || '_' || InstalledExternalProducts.productId 168 | FROM InstalledExternalProducts 169 | JOIN Platforms ON InstalledExternalProducts.platformId = Platforms.id 170 | UNION 171 | SELECT 'gog_' || productId FROM InstalledProducts) 172 | AND GamePieceTypes.type = 'originalTitle'""" 173 | 174 | self.log.debug(f"Running query: {query}") 175 | self.cursor.execute(query) 176 | installed_games = [] 177 | # Release keys are each in their own list. Should only be one element per 178 | # list, but let's not assume that. Put all results into a single list 179 | for result in self.cursor.fetchall(): 180 | for r in result: 181 | installed_games.append(r) 182 | 183 | return installed_games 184 | 185 | def get_common_games(self): 186 | game_list = {} 187 | self.owners_to_match = [] 188 | 189 | # Loop through all the DBs and get info on all owned titles 190 | for db_file in self.config["db_list"]: 191 | self.log.debug("Using DB {}".format(db_file)) 192 | self.use_db(db_file) 193 | userid = self.get_user()[0] 194 | self.owners_to_match.append(userid) 195 | self.gamepiecetype_id = self.get_gamepiecetype_id("allGameReleases") 196 | owned_games = self.get_owned_games() 197 | installed_games = self.get_installed_games() 198 | self.log.debug("owned games = {}".format(owned_games)) 199 | # A row looks like (release_keys {"title": "Title Name"}) 200 | for release_keys, title_json in owned_games: 201 | # If a game is owned on multiple platforms, the release keys will be comma-separated 202 | for release_key in release_keys.split(","): 203 | # Release keys start with the platform 204 | platform = release_key.split("_")[0] 205 | 206 | if platform in self.config["exclude_platforms"]: 207 | self.log.debug( 208 | f"{release_key}: skipping as {platform} is excluded" 209 | ) 210 | continue 211 | 212 | if release_key not in game_list: 213 | # This is the first we've seen this title, so add it 214 | title = json.loads(title_json)["title"] 215 | # epic_daac7fe46e3647cb80530411d7ec1dc5 (The Fall) has no data 216 | if title is None: 217 | self.log.debug( 218 | f"{release_key}: skipping as it has a null title" 219 | ) 220 | continue 221 | slug = get_slug_from_title(title) 222 | if slug in self.config["hidden"]: 223 | self.log.debug( 224 | f"{release_key} ({title}): skipping as it's hidden" 225 | ) 226 | continue 227 | 228 | game_list[release_key] = { 229 | "title": title, 230 | "slug": slug, 231 | "owners": [], 232 | "installed": [], 233 | } 234 | 235 | # Get the best key to use for IGDB 236 | if platform == "steam": 237 | game_list[release_key]["igdb_key"] = release_key 238 | else: 239 | game_list[release_key]["igdb_key"] = ( 240 | self.get_igdb_release_key( 241 | self.gamepiecetype_id, release_key 242 | ) 243 | ) 244 | 245 | self.log.debug( 246 | f'{release_key}: using {game_list[release_key]["igdb_key"]} for IGDB' 247 | ) 248 | 249 | # Add metadata from the config file if we have any 250 | if slug in self.config["metadata"]: 251 | for k in self.config["metadata"][slug]: 252 | self.log.debug( 253 | "Adding metadata {} to title {}".format(k, title) 254 | ) 255 | game_list[release_key][k] = self.config["metadata"][ 256 | slug 257 | ][k] 258 | 259 | self.log.debug("User {} owns {}".format(userid, release_key)) 260 | game_list[release_key]["owners"].append(userid) 261 | game_list[release_key]["platforms"] = [platform] 262 | if release_key in installed_games: 263 | game_list[release_key]["installed"].append(userid) 264 | 265 | self.close_connection() 266 | 267 | # Sort by slug to avoid headaches in the templates; 268 | # dicts maintain insertion order as of Python 3.7 269 | ordered_game_list = { 270 | k: v for k, v in sorted(game_list.items(), key=cmp_to_key(self._sort)) 271 | } 272 | 273 | # Sort the owner lists so we can compare them easily 274 | for k in ordered_game_list: 275 | ordered_game_list[k]["owners"].sort() 276 | 277 | self.owners_to_match.sort() 278 | self.log.debug("owners_to_match: {}".format(self.owners_to_match)) 279 | 280 | return ordered_game_list 281 | 282 | def merge_duplicate_titles(self, game_list): 283 | working_game_list = copy.deepcopy(game_list) 284 | # Merge entries that have the same title and platforms 285 | keys = list(game_list) 286 | for k in keys: 287 | # Skip if we deleted this earlier, or we're at the end of the dict 288 | if k not in working_game_list or keys.index(k) >= len(keys) - 2: 289 | continue 290 | 291 | slug = game_list[k]["slug"] 292 | owners = game_list[k]["owners"] 293 | platforms = game_list[k]["platforms"] 294 | 295 | # Go through any subsequent keys with the same slug 296 | next_key = keys[keys.index(k) + 1] 297 | while game_list[next_key]["slug"] == slug: 298 | self.log.debug( 299 | "Found duplicate title {} (slug: {}), keys {}, {}".format( 300 | game_list[k]["title"], slug, k, next_key 301 | ) 302 | ) 303 | if game_list[next_key]["max_players"] > game_list[k]["max_players"]: 304 | self.log.debug( 305 | "{}: has higher max players {}, {} will inherit".format( 306 | next_key, game_list[next_key]["max_players"], k 307 | ) 308 | ) 309 | game_list[k]["max_players"] = game_list[next_key]["max_players"] 310 | 311 | if game_list[next_key]["owners"] == owners: 312 | self.log.debug( 313 | "{}: owners are the same: {}, {}".format( 314 | next_key, owners, game_list[next_key]["owners"] 315 | ) 316 | ) 317 | platform = game_list[next_key]["platforms"][0] 318 | if platform not in platforms: 319 | self.log.debug( 320 | "{}: adding new platform {} to {}".format( 321 | next_key, platform, platforms 322 | ) 323 | ) 324 | platforms.append(platform) 325 | else: 326 | self.log.debug( 327 | "{}: platform {} already in {}".format( 328 | next_key, platform, platforms 329 | ) 330 | ) 331 | 332 | self.log.debug( 333 | "{}: deleting duplicate {} as it has been merged into {}".format( 334 | next_key, game_list[next_key], game_list[k] 335 | ) 336 | ) 337 | del working_game_list[next_key] 338 | working_game_list[k]["platforms"] = sorted(platforms) 339 | else: 340 | self.log.debug( 341 | "{}: owners are different: {} {}".format( 342 | next_key, owners, game_list[next_key]["owners"] 343 | ) 344 | ) 345 | 346 | if keys.index(next_key) >= len(keys) - 2: 347 | break 348 | 349 | next_key = keys[keys.index(next_key) + 1] 350 | 351 | return working_game_list 352 | 353 | def filter_games(self, game_list, all_games=False): 354 | """ 355 | Removes games that don't fit the search criteria. Note that 356 | we will not filter a game we have no multiplayer info on 357 | """ 358 | working_game_list = copy.deepcopy(game_list) 359 | 360 | for k in game_list: 361 | # Remove single-player games if we didn't ask for them 362 | if ( 363 | not self.config["include_single_player"] 364 | and not game_list[k]["multiplayer"] 365 | ): 366 | self.log.debug(f"{k}: Removing as it is single player") 367 | del working_game_list[k] 368 | continue 369 | 370 | # If all games was chosen, we don't want to filter anything else 371 | if all_games: 372 | continue 373 | 374 | # Delete any entries that aren't owned by all users we want 375 | for owner in self.config["user_ids_to_compare"]: 376 | if owner not in game_list[k]["owners"]: 377 | self.log.debug( 378 | f'Deleting {game_list[k]["title"]} as owners {game_list[k]["owners"]} does not include {owner}' 379 | ) 380 | del working_game_list[k] 381 | break 382 | elif ( 383 | self.config["installed_only"] 384 | and owner not in game_list[k]["installed"] 385 | ): 386 | self.log.debug( 387 | f'Deleting {game_list[k]["title"]} as it\'s not installed by {owner}' 388 | ) 389 | del working_game_list[k] 390 | break 391 | # This only executes if the for loop didn't break 392 | else: 393 | for owner in self.config["user_ids_to_exclude"]: 394 | if owner in game_list[k]["owners"]: 395 | self.log.debug( 396 | "Deleting {} as owners {} includes {} and exclusive is true".format( 397 | game_list[k]["title"], 398 | game_list[k]["owners"], 399 | owner, 400 | ) 401 | ) 402 | del working_game_list[k] 403 | break 404 | 405 | return working_game_list 406 | 407 | def get_caption(self, num_games, random=False): 408 | """Returns the caption string""" 409 | 410 | if random: 411 | caption_start = f"Random game selected from {num_games}" 412 | else: 413 | caption_start = num_games 414 | 415 | if self.config["all_games"]: 416 | caption_middle = "total games owned by" 417 | elif len(self.config["user_ids_to_compare"]) == 1: 418 | caption_middle = "games owned by" 419 | else: 420 | caption_middle = "games in common between" 421 | 422 | usernames_excluded = "" 423 | if self.config["user_ids_to_exclude"] and not self.config["all_games"]: 424 | usernames = [ 425 | self.config["users"][userid]["username"] 426 | for userid in self.config["user_ids_to_exclude"] 427 | ] 428 | usernames_excluded = f' and not owned by {", ".join(usernames)}' 429 | 430 | platforms_excluded = "" 431 | if self.config["exclude_platforms"]: 432 | platforms_excluded = " ({} excluded)".format( 433 | ", ".join(self.config["exclude_platforms"]).title() 434 | ) 435 | 436 | self.log.debug("platforms_excluded = {}".format(platforms_excluded)) 437 | 438 | installed = "" 439 | if self.config["installed_only"] and not self.config["all_games"]: 440 | installed = " (installed only)" 441 | 442 | usernames = [] 443 | for userid in self.config["user_ids_to_compare"]: 444 | usernames.append(self.config["users"][userid]["username"]) 445 | 446 | return "{} {} {}{}{}{}".format( 447 | caption_start, 448 | caption_middle, 449 | ", ".join(usernames), 450 | usernames_excluded, 451 | platforms_excluded, 452 | installed, 453 | ) 454 | 455 | # Props to nradoicic! 456 | def _sort(self, a, b): 457 | """Does a primary sort by slug, and secondary sort by platforms 458 | so that steam and gog are first; we prefer those when removing 459 | dups, as we can currently only get IGDB data for them 460 | """ 461 | platforms = PLATFORMS 462 | title_a = a[1]["slug"] 463 | title_b = b[1]["slug"] 464 | if title_a == title_b: 465 | platform_a = a[1]["platforms"][0] 466 | platform_b = b[1]["platforms"][0] 467 | for platform in [platform_a, platform_b]: 468 | if platform not in platforms: 469 | self.log.warning(f"Unknown platform {platform}, not sorting") 470 | return 0 471 | index_a = platforms.index(platform_a) 472 | index_b = platforms.index(platform_b) 473 | if index_a < index_b: 474 | return -1 475 | if index_a > index_b: 476 | return 1 477 | return 0 478 | if title_a < title_b: 479 | return -1 480 | if title_a > title_b: 481 | return 1 482 | -------------------------------------------------------------------------------- /src/gamatrix/helpers/igdb_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | import time 4 | 5 | from gamatrix.helpers.constants import ( 6 | IGDB_API_CALL_DELAY, 7 | IGDB_GAME_MODE, 8 | IGDB_MAX_PLAYER_KEYS, 9 | IGDB_PLATFORM_ID, 10 | ) 11 | 12 | 13 | class IGDBHelper: 14 | def __init__(self, client_id, client_secret, cache): 15 | self.cache = cache 16 | self.client_id = client_id 17 | self.client_secret = client_secret 18 | self.log = logging.getLogger(__name__) 19 | self.api_failures = 0 20 | self.last_api_call_time = time.time() 21 | self.platform_id = IGDB_PLATFORM_ID 22 | self.game_mode = IGDB_GAME_MODE 23 | self.max_player_keys = IGDB_MAX_PLAYER_KEYS 24 | self.api_call_delay = IGDB_API_CALL_DELAY 25 | self.access_token = {} 26 | self._init_cache() 27 | if not self.access_token: 28 | self.log.info("No access token in cache, getting new one") 29 | self.get_access_token() 30 | self._set_headers() 31 | 32 | def _init_cache(self): 33 | if "igdb" not in self.cache: 34 | self.cache["igdb"] = {} 35 | if "games" not in self.cache["igdb"]: 36 | self.cache["igdb"]["games"] = {} 37 | if "access_token" in self.cache["igdb"]: 38 | self.access_token = self.cache["igdb"]["access_token"] 39 | 40 | def get_access_token(self): 41 | """Gets a new access token. Returns True on success, False on failure""" 42 | url = "https://id.twitch.tv/oauth2/token" 43 | payload = { 44 | "client_id": self.client_id, 45 | "client_secret": self.client_secret, 46 | "grant_type": "client_credentials", 47 | } 48 | 49 | request_succeeded = True 50 | 51 | try: 52 | r = requests.post(url, params=payload) 53 | except Exception as e: 54 | self.log.error(f"Request to IGDB failed trying to get access token: {e}") 55 | request_succeeded = False 56 | 57 | if not request_succeeded: 58 | pass 59 | elif r.status_code != 200: 60 | self.log.error( 61 | "Failed to get access token: {} (status code {})".format( 62 | r.text, r.status_code 63 | ) 64 | ) 65 | elif "access_token" not in r.json(): 66 | self.log.error( 67 | f"Request succeeded, but access_token not found in response: {r.text}" 68 | ) 69 | else: 70 | self.log.info(f"Access token request succeeded, response: {r.text}") 71 | self.access_token = r.json()["access_token"] 72 | self.cache["igdb"]["access_token"] = self.access_token 73 | self._set_headers() 74 | self.api_failures = 0 75 | return True 76 | 77 | self.access_token = {} 78 | self.api_failures += 1 79 | return False 80 | 81 | def api_request(self, url, body): 82 | """Makes an API request with retries, honoring the rate 83 | limit and backing off when we have failed calls 84 | """ 85 | if not self.access_token: 86 | self.log.error("We have no access token, skipping IGDB request") 87 | return {} 88 | 89 | self.cache["dirty"] = True 90 | 91 | # Back off when we have failed requests 92 | if self.api_failures > 0: 93 | sleep_secs = 2 * self.api_failures 94 | self.log.info(f"{self.api_failures} API failures, sleeping {sleep_secs}") 95 | time.sleep(sleep_secs) 96 | 97 | while True: 98 | # Respect the API rate limit 99 | secs_since_last_call = time.time() - self.last_api_call_time 100 | 101 | if secs_since_last_call < self.api_call_delay: 102 | secs_to_wait = self.api_call_delay - secs_since_last_call 103 | self.log.debug( 104 | f"{secs_since_last_call:.3f}s since last API call, waiting {secs_to_wait:.3f}s" 105 | ) 106 | time.sleep(secs_to_wait) 107 | 108 | self.last_api_call_time = time.time() 109 | self.log.debug( 110 | f"Sending API request to {url}, headers = '{self.headers}', body = '{body}'" 111 | ) 112 | try: 113 | r = requests.post(url, headers=self.headers, data=body) 114 | except Exception as e: 115 | self.log.error(f"Request to IGDB failed: {e}") 116 | self.api_failures += 1 117 | return {} 118 | 119 | self.log.debug(f"Response = {r.text} (status code {r.status_code})") 120 | 121 | if r.status_code == 200: 122 | self.api_failures = 0 123 | return r.json() 124 | elif r.status_code == 401: 125 | self.log.info("Got 401 Unauthorized, getting new access token") 126 | # If this fails, avoid spamming the API 127 | if not self.get_access_token(): 128 | self.api_failures += 1 129 | return {} 130 | # This shouldn't happen, but just in case 131 | elif r.status_code == 429: 132 | sleep_secs = self.api_call_delay * 2 133 | self.log.info(f"Rate limit exceeded, sleeping {sleep_secs}s") 134 | time.sleep(sleep_secs) 135 | else: 136 | self.log.error( 137 | f"Request failed, response: {r.text} (status code {r.status_code})" 138 | ) 139 | self.api_failures += 1 140 | return {} 141 | 142 | def get_game_info(self, release_key, update=False): 143 | """Gets some game info for release_key""" 144 | if release_key not in self.cache["igdb"]["games"]: 145 | self.log.error(f"{release_key}: not in cache; use get_igdb_id() first") 146 | return 147 | elif "info" in self.cache["igdb"]["games"][release_key] and not ( 148 | update and not self.cache["igdb"]["games"][release_key]["info"] 149 | ): 150 | self.log.debug(f"{release_key}: found game info in cache") 151 | return 152 | elif "igdb_id" not in self.cache["igdb"]["games"][release_key]: 153 | self.log.error(f"{release_key}: IGDB ID not found, can't get game info") 154 | return 155 | elif self.cache["igdb"]["games"][release_key]["igdb_id"] == 0: 156 | self.log.debug( 157 | f"{release_key}: IGDB ID is 0, not looking up game info and setting empty response in cache" 158 | ) 159 | # Save an empty response so we know we tried to look this up before 160 | response = [] 161 | self.cache["dirty"] = True 162 | else: 163 | self.log.info(f"{release_key}: getting game info from IGDB") 164 | url = "https://api.igdb.com/v4/games" 165 | body = "fields game_modes,name,parent_game,slug; where id = {};".format( 166 | self.cache["igdb"]["games"][release_key]["igdb_id"] 167 | ) 168 | 169 | response = self.api_request(url, body) 170 | 171 | self.cache["igdb"]["games"][release_key]["info"] = response 172 | return 173 | 174 | def get_igdb_id(self, release_key, update=False): 175 | """Gets the IDGB ID for release_key. Returns 176 | True if an ID was found, False if not 177 | """ 178 | if release_key not in self.cache["igdb"]["games"]: 179 | self.cache["igdb"]["games"][release_key] = {} 180 | elif self._igdb_id_in_cache(release_key, update): 181 | return True 182 | 183 | self.log.info(f"{release_key}: getting ID from IGDB") 184 | 185 | # Using maxsplit prevents keys like battlenet_hs_beta from hosing us 186 | platform, platform_key = release_key.split("_", 1) 187 | 188 | body = f'fields game; where uid = "{platform_key}"' 189 | # If we have a platform ID, specify it 190 | if platform in self.platform_id: 191 | body += f" & category = {self.platform_id[platform]}" 192 | 193 | body += ";" 194 | url = "https://api.igdb.com/v4/external_games" 195 | 196 | response = self.api_request(url, body) 197 | 198 | if response: 199 | # That gives [{"id": 8104, "game": 7351}]; game is the igdb id 200 | # The response is a list of all external IDs; they'll all have the same 201 | # value for "game" so just get the first one 202 | self.cache["igdb"]["games"][release_key]["igdb_id"] = response[0]["game"] 203 | self.log.debug(f'{release_key}: got IGDB ID {response[0]["game"]}') 204 | else: 205 | self.log.debug(f"{release_key}: not found in IGDB") 206 | return False 207 | 208 | return True 209 | 210 | def get_igdb_id_by_slug(self, release_key, slug, update=False): 211 | """Gets the IDGB ID for release_key by title. Returns 212 | True if an ID was found, False if not 213 | """ 214 | if release_key not in self.cache["igdb"]["games"]: 215 | self.cache["igdb"]["games"][release_key] = {} 216 | elif self._igdb_id_in_cache(release_key, update): 217 | return True 218 | 219 | body = f'fields id,name; where slug = "{slug}";' 220 | url = "https://api.igdb.com/v4/games" 221 | 222 | response = self.api_request(url, body) 223 | 224 | if response: 225 | # That gives [{"id": 8104, "name": "blah"}]; id is the igdb id 226 | self.cache["igdb"]["games"][release_key]["igdb_id"] = response[0]["id"] 227 | self.log.debug( 228 | f'{release_key}: got IGDB ID {response[0]["id"]} with slug lookup {slug}' 229 | ) 230 | else: 231 | # If we don't get an ID, set it to 0 so we know we've looked this game up before 232 | self.log.debug( 233 | f"{release_key}: not found in IGDB with slug lookup {slug}, setting ID to 0" 234 | ) 235 | self.cache["igdb"]["games"][release_key]["igdb_id"] = 0 236 | return False 237 | 238 | return True 239 | 240 | def get_multiplayer_info(self, release_key, update=False): 241 | """Gets the multiplayer info for release_key""" 242 | if release_key not in self.cache["igdb"]["games"]: 243 | self.log.error(f"{release_key} not in cache; use get_igdb_id() first") 244 | return 245 | 246 | elif "max_players" in self.cache["igdb"]["games"][release_key] and not ( 247 | update and self.cache["igdb"]["games"][release_key]["max_players"] == 0 248 | ): 249 | self.log.debug( 250 | "Found max players {} for release key {} in cache".format( 251 | self.cache["igdb"]["games"][release_key]["max_players"], 252 | release_key, 253 | ) 254 | ) 255 | return 256 | 257 | elif "igdb_id" not in self.cache["igdb"]["games"][release_key]: 258 | self.log.error(f"{release_key}: IGDB ID not found, can't get max players") 259 | return 260 | 261 | elif self.cache["igdb"]["games"][release_key]["igdb_id"] == 0: 262 | self.log.debug( 263 | f"{release_key}: IGDB ID is 0, not looking up multiplayer info and setting empty response in cache" 264 | ) 265 | # Set an empty response so we know we tried to look this up before 266 | response = [] 267 | self.cache["dirty"] = True 268 | 269 | else: 270 | self.log.info(f"{release_key}: getting multiplayer info from IGDB") 271 | # Get the multiplayer info 272 | url = "https://api.igdb.com/v4/multiplayer_modes" 273 | body = f'fields *; where game = {self.cache["igdb"]["games"][release_key]["igdb_id"]};' 274 | response = self.api_request(url, body) 275 | 276 | self.cache["igdb"]["games"][release_key]["multiplayer"] = response 277 | self.cache["igdb"]["games"][release_key]["max_players"] = self._get_max_players( 278 | response 279 | ) 280 | 281 | def _get_max_players(self, multiplayer_info): 282 | """Returns the max_players value for release_key, which is the 283 | highest value of any of the various max player keys from IGDB 284 | """ 285 | max_players = 0 286 | 287 | # multiplayer_info is a list of all platforms it has data for; 288 | # e.g., "Xbox One" and "All platforms". Each of these can have some or all 289 | # of the keys we're looking for, and the data can be inconsistent between 290 | # platforms. So, loop through all platforms and grab the max value we see 291 | for platform in multiplayer_info: 292 | for mp_key in self.max_player_keys: 293 | if mp_key in platform and platform[mp_key] > max_players: 294 | max_players = platform[mp_key] 295 | self.log.debug(f"Found new max_players {max_players}, key {mp_key}") 296 | 297 | return max_players 298 | 299 | def _igdb_id_in_cache(self, release_key, update=False): 300 | """Returns True if the IGDB ID is in the cache""" 301 | if "igdb_id" not in self.cache["igdb"]["games"][release_key]: 302 | self.log.debug(f"{release_key}: no IGDB ID in cache") 303 | return False 304 | 305 | if self.cache["igdb"]["games"][release_key]["igdb_id"] == 0: 306 | if update: 307 | self.log.debug( 308 | f"{release_key}: IGDB ID is 0 and update is {update}, cache miss" 309 | ) 310 | return False 311 | else: 312 | self.log.debug( 313 | f"{release_key}: IGDB ID is 0 and update is {update}, cache hit" 314 | ) 315 | else: 316 | self.log.debug( 317 | f'{release_key}: found IGDB ID {self.cache["igdb"]["games"][release_key]["igdb_id"]} in cache' 318 | ) 319 | 320 | return True 321 | 322 | def _set_headers(self): 323 | self.headers = { 324 | "Client-ID": self.client_id, 325 | "Authorization": f"Bearer {self.access_token}", 326 | } 327 | -------------------------------------------------------------------------------- /src/gamatrix/helpers/misc_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | log = logging.getLogger(__name__) 5 | 6 | 7 | def get_slug_from_title(title): 8 | """Returns a URL-safe version of title. This is used to match the 9 | IGDB slug if more accurate methods fail, and to match the title 10 | better when dealing with slightly different titles across platforms 11 | """ 12 | slug = "-" 13 | 14 | if not isinstance(title, str): 15 | log.warning(f'{title} is type {type(title)}, not string; using slug "{slug}"') 16 | else: 17 | # The IGDB slug algorithm is not published, but this is pretty close 18 | # A little less than half the time, an apostrophe is replaced with a dash, 19 | # so we'll miss those 20 | slug = title.replace("/", " slash ") 21 | # Remove special characters and replace whitespace with dashes 22 | slug = re.sub(r"\s+", "-", slug) 23 | slug = re.sub(r"[^0-9A-Za-z-]", "", slug).lower() 24 | # Collapse dashes for titles like "Dragon Age - Definitive Edition" 25 | slug = re.sub(r"[-]+", "-", slug) 26 | 27 | if not slug: 28 | slug = "-" 29 | log.warning( 30 | f'Converting {title} to slug yielded an empty string, using "{slug}"' 31 | ) 32 | 33 | return slug 34 | -------------------------------------------------------------------------------- /src/gamatrix/helpers/network_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import abort 4 | from ipaddress import IPv4Address 5 | 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | def _ip_allowed(ip, networks): 11 | """Returns True if ip is in any of the networks, False otherwise""" 12 | # If no CIDRs are defined, all IPs are allowed 13 | ip_allowed = not networks 14 | 15 | ip = IPv4Address(ip) 16 | 17 | for network in networks: 18 | if ip in network: 19 | ip_allowed = True 20 | break 21 | 22 | return ip_allowed 23 | 24 | 25 | def check_ip_is_authorized(ip, networks): 26 | if not _ip_allowed(ip, networks): 27 | log.info(f"Rejecting request from unauthorized IP {ip}") 28 | abort(401) 29 | 30 | log.info(f"Accepted request from {ip}") 31 | -------------------------------------------------------------------------------- /src/gamatrix/static/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/__init__.py -------------------------------------------------------------------------------- /src/gamatrix/static/battlenet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/battlenet.png -------------------------------------------------------------------------------- /src/gamatrix/static/bethesda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/bethesda.png -------------------------------------------------------------------------------- /src/gamatrix/static/epic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/epic.png -------------------------------------------------------------------------------- /src/gamatrix/static/gog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/gog.png -------------------------------------------------------------------------------- /src/gamatrix/static/origin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/origin.png -------------------------------------------------------------------------------- /src/gamatrix/static/profile_img/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/profile_img/__init__.py -------------------------------------------------------------------------------- /src/gamatrix/static/profile_img/question_block.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/profile_img/question_block.jpg -------------------------------------------------------------------------------- /src/gamatrix/static/steam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/steam.png -------------------------------------------------------------------------------- /src/gamatrix/static/uplay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/uplay.png -------------------------------------------------------------------------------- /src/gamatrix/static/xboxone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/static/xboxone.png -------------------------------------------------------------------------------- /src/gamatrix/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eniklas/gamatrix/c44864c08284901daf4f4992741f46721e780e21/src/gamatrix/templates/__init__.py -------------------------------------------------------------------------------- /src/gamatrix/templates/game_grid.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 22 | 23 | gamatrix results 24 | 25 | 26 | 27 | 28 | Back to home page 29 |

30 | {{ debug_str }} 31 | 32 | 33 | 34 | 35 | {% for userid, value in users.items() -%} 36 | 37 | {%- endfor %} 38 | 39 | 40 | {% if show_keys -%} 41 | 42 | {%- endif %} 43 | 44 | {% for game, value in games.items() -%} 45 | {%- set outer_loop = loop -%} 46 | 47 | 61 | {% for userid, uvalue in users.items() -%} 62 | {% if userid in value.owners -%} 63 | 74 | {%- endfor %} 75 | 80 | 81 | {% if show_keys -%} 82 | 83 | {%- endif -%} 84 | 85 | {%- endfor %} 86 |
{{ caption }}
Title{{ value.username }}PlayersCommentRelease Key
48 | {% if value.url is defined -%} 49 | 50 | {%- endif -%} 51 | {{ value.title }} 52 | {%- if value.url is defined -%} 53 | 54 | {%- endif -%} 55 |
56 | {% for platform in value.platforms -%} 57 |   58 | {%- endfor -%} 59 |
60 |
64 | {%- else -%} 65 | 66 | {%- endif -%} 67 | {% if outer_loop.index is divisibleby 25 -%} 68 | {{ uvalue.username }} 69 | {%- endif -%} 70 | {%- if userid in value.installed -%} 71 | 72 | {%- endif -%} 73 | 76 | {%- if value.max_players > 0 -%} 77 | {{ value.max_players }} 78 | {%- endif -%} 79 | {{ value.comment }}{{ game }}
87 |

88 | Back to home page 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/gamatrix/templates/game_list.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 22 | 23 | gamatrix results 24 | 25 | 26 | 27 | 28 | Back to home page 29 |

30 | {{ debug_str }} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% if show_keys -%} 39 | 40 | {%- endif %} 41 | 42 | {% for game, value in games.items() -%} 43 | {% set max_players = value.max_players|default("0", true)|int -%} 44 | {# Alternate row colors, and grey out titles that support fewer players than selected #} 45 | 50 | 68 | 73 | {# Put a check mark if all users have the game installed #} 74 | 89 | 90 | {% if show_keys -%} 91 | 92 | {%- endif -%} 93 | 94 | {%- endfor -%} 95 |
{{ caption }}
TitlePlayersInstalledCommentRelease Key
51 | {% if value.url is defined -%} 52 | 53 | {%- endif -%} 54 | {{ value.title }} 55 | {%- if value.url is defined -%} 56 | 57 | {%- endif -%} 58 |
59 | {% for platform in value.platforms -%} 60 | {%- if platform not in platforms -%} 61 |   62 | {%- else -%} 63 |   64 | {%- endif -%} 65 | {%- endfor -%} 66 |
67 |
69 | {%- if max_players > 0 -%} 70 | {{ max_players }} 71 | {%- endif -%} 72 | 75 | {% if users|length == value.installed|length -%} 76 | 77 | {# Otherwise list the users that have it installed, using their pic if available #} 78 | {%- else -%} 79 | {% for userid in value.installed -%} 80 | {% if users[userid]['pic'] is defined -%} 81 |   83 | {%- else -%} 84 | {{ users[userid]['username'] }}  85 | {%- endif -%} 86 | {%- endfor -%} 87 | {%- endif -%} 88 | {{ value.comment }}{{ game }}
96 | {% if randomize -%} 97 |

Refresh the page to reroll the dice!

98 | {%- endif -%} 99 |
100 | Back to home page 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/gamatrix/templates/index.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 33 | 34 | gamatrix 35 | 36 | 37 | 38 | 39 |

gamatrix

40 | 41 | 42 | 43 | 68 | 69 | 73 | {%- if uploads_enabled -%} 74 | 78 | {%- endif -%} 79 | 80 | 81 | 82 | 83 | 84 | 117 | 118 | 119 |
44 | 45 |
46 |
47 |
48 | {% for user in users -%} 49 | 58 | {% endfor -%} 59 |
60 |
62 | 63 | 64 |
65 |
66 |
67 |
70 | 71 |
72 |
75 | 76 |
77 |
85 |
86 |

87 | 88 | 89 | {% for batch in platforms|batch(2) -%} 90 | 91 | {%- for platform in batch %} 92 | 97 | {%- endfor %} 98 | 99 | {% endfor -%} 100 |
Exclude platforms:
93 | 94 | 96 |
101 |
102 |

103 | 104 |
105 |
106 | 107 |
108 | 109 |
110 | 111 |
112 |

113 |
114 |
115 |
116 |
120 | 121 |

122 | v{{ version }} 123 |

124 | 125 | -------------------------------------------------------------------------------- /src/gamatrix/templates/upload_status.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | gamatrix upload status 16 | 17 | 18 | 19 | 20 | {{ message }} 21 |

22 | Back to home page 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/test_cmdline.py: -------------------------------------------------------------------------------- 1 | """Test for command line switches affecting the config. 2 | 3 | Currently, these are the values you can affect via the command line: 4 | 5 | mode: client | server # default is client, use -s 6 | interface: (valid interface address) # default is 0.0.0.0, use -i 7 | port: (valid port) # default is 8080, use -p 8 | include_single_player: True | False # use -I 9 | all_games: True | False # use -a 10 | """ 11 | 12 | from typing import Any, List 13 | 14 | import pytest 15 | 16 | import gamatrix.__main__ as gog 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "description,commandline,config_fields,expected_values", 21 | [ 22 | [ 23 | "No switches", # Description, should this test pass fail. 24 | [ 25 | "./gamatrix.py", # standard, just left here to simulate actual command line argv list... 26 | "--config-file", # use long switch names, more descriptive this way 27 | "./config-sample.yaml", # use the sample yaml as a test data source 28 | ], 29 | [ 30 | "mode", # names of the top-level field in the config file, in this case mode 31 | "interface", 32 | "port", 33 | "include_single_player", 34 | "all_games", 35 | ], 36 | [ 37 | "server", # values that are expected, this list is arranged to coincide with fields in the same order as the list above 38 | "0.0.0.0", 39 | 8080, 40 | False, 41 | False, 42 | ], 43 | ], 44 | [ 45 | "Assorted values all in one", 46 | [ 47 | "./gamatrix.py", # just here to simulate actual command line argv list... 48 | "--config-file", 49 | "./config-sample.yaml", 50 | "--server", 51 | "--interface", 52 | "1.2.3.4", 53 | "--port", 54 | "62500", 55 | "--include-single-player", 56 | "--all-games", 57 | ], 58 | ["mode", "interface", "port", "include_single_player", "all_games"], 59 | [ 60 | "server", 61 | "1.2.3.4", 62 | 62500, 63 | True, 64 | True, 65 | ], 66 | ], 67 | [ 68 | "Only set the mode to server", 69 | [ 70 | "./gamatrix.py", 71 | "--config-file", 72 | "./config-sample.yaml", 73 | "--server", 74 | ], 75 | ["mode"], 76 | ["server"], 77 | ], 78 | [ 79 | "Allow the cache to update missing items.", 80 | [ 81 | "./gamatrix.py", 82 | "--config-file", 83 | "./config-sample.yaml", 84 | "--update-cache", 85 | ], 86 | ["mode", "update_cache"], 87 | ["server", True], 88 | ], 89 | ], 90 | ) 91 | def test_new_cmdline_handling( 92 | description: str, 93 | commandline: List[str], 94 | config_fields: List[str], 95 | expected_values: List[Any], 96 | ): 97 | """Parse the command line and build the config file, checking for problems.""" 98 | args = gog.parse_cmdline( 99 | argv=commandline[1:], docstr=str(gog.__doc__), version="1.4.5" 100 | ) 101 | config = gog.build_config(args) 102 | for i in range(len(config_fields)): 103 | assert ( 104 | config[config_fields[i]] == expected_values[i] 105 | ), f"Failure for pass: '{description}'" 106 | -------------------------------------------------------------------------------- /test/test_gogdb.py: -------------------------------------------------------------------------------- 1 | """Tests for GOG db related functions.""" 2 | 3 | from gamatrix.helpers import gogdb_helper 4 | 5 | 6 | def test_is_sqlite3_not_enough_data(): 7 | assert not gogdb_helper.is_sqlite3(b"not long enough") 8 | 9 | 10 | long_enough_but_wrong_header_values = b"""0123456789 11 | 01234567890123456789012345678901234567890123456789 12 | 01234567890123456789012345678901234567890123456789 13 | """ 14 | 15 | 16 | def test_is_sqlite3_wrong_header_data(): 17 | assert not gogdb_helper.is_sqlite3(long_enough_but_wrong_header_values) 18 | 19 | 20 | good_data = b"""SQLite format 3\000 21 | 0123456789 22 | 0123456789 23 | 0123456789 24 | 0123456789 25 | 0123456789 26 | 0123456789 27 | 0123456789 28 | """ 29 | 30 | 31 | def test_is_sqlite3_good_header_data(): 32 | assert gogdb_helper.is_sqlite3(good_data) 33 | --------------------------------------------------------------------------------