├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── pythonpackage.yml ├── .gitignore ├── .sonarcloud.properties ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Pipfile ├── Pipfile.lock ├── publish.sh ├── readme.md ├── setup.py ├── tests ├── __init__.py ├── test_helix_user.py ├── test_helix_video.py └── test_twitch.py ├── tox.ini └── twitch ├── __init__.py ├── api.py ├── baseresource.py ├── cache.py ├── chat ├── __init__.py ├── chat.py ├── irc.py └── message.py ├── helix ├── __init__.py ├── helix.py ├── models │ ├── __init__.py │ ├── clip.py │ ├── follow.py │ ├── game.py │ ├── model.py │ ├── stream.py │ ├── user.py │ └── video.py └── resources │ ├── __init__.py │ ├── clips.py │ ├── follows.py │ ├── games.py │ ├── resource.py │ ├── streams.py │ ├── users.py │ └── videos.py ├── resource.py ├── tmi ├── __init__.py ├── models │ ├── __init__.py │ ├── chatter.py │ └── model.py ├── resources │ ├── __init__.py │ └── chatters.py └── tmi.py └── v5 ├── __init__.py ├── models ├── __init__.py ├── comment.py └── model.py ├── resources ├── __init__.py └── comments.py └── v5.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.py] 8 | indent_style = space 9 | indent_size = 4 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [] 2 | custom: ['https://www.paypal.me/petterkraabol'] 3 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | max-parallel: 4 11 | matrix: 12 | python-version: [3.10.4] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | pip install pipenv --upgrade 23 | pipenv install --dev 24 | - name: Run test 25 | run: | 26 | pipenv run tox 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | dev/ 3 | .idea/ 4 | twitch-python.iml 5 | 6 | # CMake 7 | cmake-build-*/ 8 | 9 | # Mongo Explorer plugin 10 | .idea/**/mongoSettings.xml 11 | 12 | # File-based project format 13 | *.iws 14 | 15 | # IntelliJ 16 | out/ 17 | 18 | # mpeltonen/sbt-idea plugin 19 | .idea_modules/ 20 | 21 | # JIRA plugin 22 | atlassian-ide-plugin.xml 23 | 24 | # Cursive Clojure plugin 25 | .idea/replstate.xml 26 | 27 | # Crashlytics plugin (for Android Studio and IntelliJ) 28 | com_crashlytics_export_strings.xml 29 | crashlytics.properties 30 | crashlytics-build.properties 31 | fabric.properties 32 | 33 | # Editor-based Rest Client 34 | .idea/httpRequests 35 | ### macOS template 36 | # General 37 | .DS_Store 38 | .AppleDouble 39 | .LSOverride 40 | 41 | # Icon must end with two \r 42 | Icon 43 | 44 | # Thumbnails 45 | ._* 46 | 47 | # Files that might appear in the root of a volume 48 | .DocumentRevisions-V100 49 | .fseventsd 50 | .Spotlight-V100 51 | .TemporaryItems 52 | .Trashes 53 | .VolumeIcon.icns 54 | .com.apple.timemachine.donotpresent 55 | 56 | # Directories potentially created on remote AFP share 57 | .AppleDB 58 | .AppleDesktop 59 | Network Trash Folder 60 | Temporary Items 61 | .apdisk 62 | ### Windows template 63 | # Windows thumbnail cache files 64 | Thumbs.db 65 | ehthumbs.db 66 | ehthumbs_vista.db 67 | 68 | # Dump file 69 | *.stackdump 70 | 71 | # Folder config file 72 | [Dd]esktop.ini 73 | 74 | # Recycle Bin used on file shares 75 | $RECYCLE.BIN/ 76 | 77 | # Windows Installer files 78 | *.cab 79 | *.msi 80 | *.msix 81 | *.msm 82 | *.msp 83 | 84 | # Windows shortcuts 85 | *.lnk 86 | ### Python template 87 | # Byte-compiled / optimized / DLL files 88 | __pycache__/ 89 | *.py[cod] 90 | *$py.class 91 | 92 | # C extensions 93 | *.so 94 | 95 | # Distribution / packaging 96 | .Python 97 | build/ 98 | develop-eggs/ 99 | dist/ 100 | downloads/ 101 | eggs/ 102 | .eggs/ 103 | lib/ 104 | lib64/ 105 | parts/ 106 | sdist/ 107 | var/ 108 | wheels/ 109 | *.egg-info/ 110 | .installed.cfg 111 | *.egg 112 | MANIFEST 113 | 114 | # PyInstaller 115 | # Usually these files are written by a python script from a template 116 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 117 | *.manifest 118 | *.spec 119 | 120 | # Installer logs 121 | pip-log.txt 122 | pip-delete-this-directory.txt 123 | 124 | # Unit test / coverage reports 125 | htmlcov/ 126 | .tox/ 127 | .coverage 128 | .coverage.* 129 | .cache 130 | nosetests.xml 131 | coverage.xml 132 | *.cover 133 | .hypothesis/ 134 | .pytest_cache/ 135 | 136 | # Translations 137 | *.mo 138 | *.pot 139 | 140 | # Django stuff: 141 | *.log 142 | local_settings.py 143 | db.sqlite3 144 | 145 | # Flask stuff: 146 | instance/ 147 | .webassets-cache 148 | 149 | # Scrapy stuff: 150 | .scrapy 151 | 152 | # Sphinx documentation 153 | docs/_build/ 154 | 155 | # PyBuilder 156 | target/ 157 | 158 | # Jupyter Notebook 159 | .ipynb_checkpoints 160 | 161 | # pyenv 162 | .python-version 163 | 164 | # celery beat schedule file 165 | celerybeat-schedule 166 | 167 | # SageMath parsed files 168 | *.sage.py 169 | 170 | # Environments 171 | .env 172 | .venv 173 | env/ 174 | venv/ 175 | ENV/ 176 | env.bak/ 177 | venv.bak/ 178 | 179 | # Spyder project settings 180 | .spyderproject 181 | .spyproject 182 | 183 | # Rope project settings 184 | .ropeproject 185 | 186 | # mkdocs documentation 187 | /site 188 | 189 | # mypy 190 | .mypy_cache/ 191 | 192 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | # Path to sources 2 | sonar.sources=twitch/ 3 | #sonar.exclusions= 4 | #sonar.inclusions= 5 | 6 | # Path to tests 7 | sonar.tests=tests/ 8 | #sonar.test.exclusions= 9 | #sonar.test.inclusions= 10 | 11 | # Source encoding 12 | sonar.sourceEncoding=UTF-8 13 | 14 | # Exclusions for copy-paste detection 15 | #sonar.cpd.exclusions= 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.8' 4 | install: 5 | - 'pip install pipenv --upgrade' 6 | - 'pipenv install --dev' 7 | script: 'pipenv run tox' 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Petter Kraabøl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include readme.md LICENSE Pipfile Pipfile.lock 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #.PHONY publish 2 | publish: 3 | pipenv run python setup.py sdist bdist_wheel 4 | pipenv run twine upload dist/* 5 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests = "2.27.1" 8 | rx = "3.2.0" 9 | 10 | [dev-packages] 11 | pipenv = "2022.4.30" 12 | twine = "4.0.0" 13 | pytest-cov = "3.0.0" 14 | pytest = "7.1.2" 15 | responses = "0.20.0" 16 | wheel = "0.37.1" 17 | tox = "3.25.0" 18 | 19 | [requires] 20 | python_version = "3.10" 21 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "23e6ffa31ff708e54e8bf8ac800757f76fbae924761c92246cbe7850d28fba9c" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.10" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 22 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 23 | ], 24 | "version": "==2021.10.8" 25 | }, 26 | "charset-normalizer": { 27 | "hashes": [ 28 | "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", 29 | "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" 30 | ], 31 | "markers": "python_version >= '3'", 32 | "version": "==2.0.12" 33 | }, 34 | "idna": { 35 | "hashes": [ 36 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 37 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 38 | ], 39 | "markers": "python_version >= '3'", 40 | "version": "==3.3" 41 | }, 42 | "requests": { 43 | "hashes": [ 44 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 45 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 46 | ], 47 | "index": "pypi", 48 | "version": "==2.27.1" 49 | }, 50 | "rx": { 51 | "hashes": [ 52 | "sha256:922c5f4edb3aa1beaa47bf61d65d5380011ff6adcd527f26377d05cb73ed8ec8", 53 | "sha256:b657ca2b45aa485da2f7dcfd09fac2e554f7ac51ff3c2f8f2ff962ecd963d91c" 54 | ], 55 | "index": "pypi", 56 | "version": "==3.2.0" 57 | }, 58 | "urllib3": { 59 | "hashes": [ 60 | "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", 61 | "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" 62 | ], 63 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 64 | "version": "==1.26.9" 65 | } 66 | }, 67 | "develop": { 68 | "atomicwrites": { 69 | "hashes": [ 70 | "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", 71 | "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" 72 | ], 73 | "markers": "sys_platform == 'win32'", 74 | "version": "==1.4.0" 75 | }, 76 | "attrs": { 77 | "hashes": [ 78 | "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", 79 | "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" 80 | ], 81 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 82 | "version": "==21.4.0" 83 | }, 84 | "bleach": { 85 | "hashes": [ 86 | "sha256:08a1fe86d253b5c88c92cc3d810fd8048a16d15762e1e5b74d502256e5926aa1", 87 | "sha256:c6d6cc054bdc9c83b48b8083e236e5f00f238428666d2ce2e083eaa5fd568565" 88 | ], 89 | "markers": "python_version >= '3.7'", 90 | "version": "==5.0.0" 91 | }, 92 | "certifi": { 93 | "hashes": [ 94 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 95 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 96 | ], 97 | "version": "==2021.10.8" 98 | }, 99 | "charset-normalizer": { 100 | "hashes": [ 101 | "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", 102 | "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" 103 | ], 104 | "markers": "python_version >= '3'", 105 | "version": "==2.0.12" 106 | }, 107 | "colorama": { 108 | "hashes": [ 109 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 110 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 111 | ], 112 | "markers": "platform_system == 'Windows'", 113 | "version": "==0.4.4" 114 | }, 115 | "commonmark": { 116 | "hashes": [ 117 | "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", 118 | "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" 119 | ], 120 | "version": "==0.9.1" 121 | }, 122 | "coverage": { 123 | "extras": [ 124 | "toml" 125 | ], 126 | "hashes": [ 127 | "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9", 128 | "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d", 129 | "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf", 130 | "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7", 131 | "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6", 132 | "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4", 133 | "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059", 134 | "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39", 135 | "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536", 136 | "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac", 137 | "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c", 138 | "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903", 139 | "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d", 140 | "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05", 141 | "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684", 142 | "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1", 143 | "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f", 144 | "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7", 145 | "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca", 146 | "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad", 147 | "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca", 148 | "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d", 149 | "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92", 150 | "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4", 151 | "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf", 152 | "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6", 153 | "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1", 154 | "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4", 155 | "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359", 156 | "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3", 157 | "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620", 158 | "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512", 159 | "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69", 160 | "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2", 161 | "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518", 162 | "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0", 163 | "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa", 164 | "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4", 165 | "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e", 166 | "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1", 167 | "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2" 168 | ], 169 | "markers": "python_version >= '3.7'", 170 | "version": "==6.3.2" 171 | }, 172 | "distlib": { 173 | "hashes": [ 174 | "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b", 175 | "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579" 176 | ], 177 | "version": "==0.3.4" 178 | }, 179 | "docutils": { 180 | "hashes": [ 181 | "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c", 182 | "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06" 183 | ], 184 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 185 | "version": "==0.18.1" 186 | }, 187 | "filelock": { 188 | "hashes": [ 189 | "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85", 190 | "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0" 191 | ], 192 | "markers": "python_version >= '3.7'", 193 | "version": "==3.6.0" 194 | }, 195 | "idna": { 196 | "hashes": [ 197 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 198 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 199 | ], 200 | "markers": "python_version >= '3'", 201 | "version": "==3.3" 202 | }, 203 | "importlib-metadata": { 204 | "hashes": [ 205 | "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6", 206 | "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539" 207 | ], 208 | "markers": "python_version >= '3.7'", 209 | "version": "==4.11.3" 210 | }, 211 | "iniconfig": { 212 | "hashes": [ 213 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 214 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 215 | ], 216 | "version": "==1.1.1" 217 | }, 218 | "keyring": { 219 | "hashes": [ 220 | "sha256:9012508e141a80bd1c0b6778d5c610dd9f8c464d75ac6774248500503f972fb9", 221 | "sha256:b0d28928ac3ec8e42ef4cc227822647a19f1d544f21f96457965dc01cf555261" 222 | ], 223 | "markers": "python_version >= '3.7'", 224 | "version": "==23.5.0" 225 | }, 226 | "packaging": { 227 | "hashes": [ 228 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 229 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 230 | ], 231 | "markers": "python_version >= '3.6'", 232 | "version": "==21.3" 233 | }, 234 | "pip": { 235 | "hashes": [ 236 | "sha256:b3a9de2c6ef801e9247d1527a4b16f92f2cc141cd1489f3fffaf6a9e96729764", 237 | "sha256:c6aca0f2f081363f689f041d90dab2a07a9a07fb840284db2218117a52da800b" 238 | ], 239 | "markers": "python_version >= '3.7'", 240 | "version": "==22.0.4" 241 | }, 242 | "pipenv": { 243 | "hashes": [ 244 | "sha256:30568d90a566148a630ce3382843d59beaede28d1c4e9045278972036ebd178c", 245 | "sha256:e26ded6ab90a7900676a1db9955d5ee714115f443aecc072b09497153ed237c7" 246 | ], 247 | "index": "pypi", 248 | "version": "==2022.4.30" 249 | }, 250 | "pkginfo": { 251 | "hashes": [ 252 | "sha256:542e0d0b6750e2e21c20179803e40ab50598d8066d51097a0e382cba9eb02bff", 253 | "sha256:c24c487c6a7f72c66e816ab1796b96ac6c3d14d49338293d2141664330b55ffc" 254 | ], 255 | "version": "==1.8.2" 256 | }, 257 | "platformdirs": { 258 | "hashes": [ 259 | "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788", 260 | "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19" 261 | ], 262 | "markers": "python_version >= '3.7'", 263 | "version": "==2.5.2" 264 | }, 265 | "pluggy": { 266 | "hashes": [ 267 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 268 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 269 | ], 270 | "markers": "python_version >= '3.6'", 271 | "version": "==1.0.0" 272 | }, 273 | "py": { 274 | "hashes": [ 275 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", 276 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" 277 | ], 278 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 279 | "version": "==1.11.0" 280 | }, 281 | "pygments": { 282 | "hashes": [ 283 | "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb", 284 | "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519" 285 | ], 286 | "markers": "python_version >= '3.6'", 287 | "version": "==2.12.0" 288 | }, 289 | "pyparsing": { 290 | "hashes": [ 291 | "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954", 292 | "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06" 293 | ], 294 | "markers": "python_full_version >= '3.6.8'", 295 | "version": "==3.0.8" 296 | }, 297 | "pytest": { 298 | "hashes": [ 299 | "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c", 300 | "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45" 301 | ], 302 | "index": "pypi", 303 | "version": "==7.1.2" 304 | }, 305 | "pytest-cov": { 306 | "hashes": [ 307 | "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6", 308 | "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470" 309 | ], 310 | "index": "pypi", 311 | "version": "==3.0.0" 312 | }, 313 | "pywin32-ctypes": { 314 | "hashes": [ 315 | "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942", 316 | "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98" 317 | ], 318 | "markers": "sys_platform == 'win32'", 319 | "version": "==0.2.0" 320 | }, 321 | "readme-renderer": { 322 | "hashes": [ 323 | "sha256:73b84905d091c31f36e50b4ae05ae2acead661f6a09a9abb4df7d2ddcdb6a698", 324 | "sha256:a727999acfc222fc21d82a12ed48c957c4989785e5865807c65a487d21677497" 325 | ], 326 | "markers": "python_version >= '3.7'", 327 | "version": "==35.0" 328 | }, 329 | "requests": { 330 | "hashes": [ 331 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 332 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 333 | ], 334 | "index": "pypi", 335 | "version": "==2.27.1" 336 | }, 337 | "requests-toolbelt": { 338 | "hashes": [ 339 | "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", 340 | "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" 341 | ], 342 | "version": "==0.9.1" 343 | }, 344 | "responses": { 345 | "hashes": [ 346 | "sha256:18831bc2d72443b67664d98038374a6fa1f27eaaff4dd9a7d7613723416fea3c", 347 | "sha256:644905bc4fb8a18fa37e3882b2ac05e610fe8c2f967d327eed669e314d94a541" 348 | ], 349 | "index": "pypi", 350 | "version": "==0.20.0" 351 | }, 352 | "rfc3986": { 353 | "hashes": [ 354 | "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", 355 | "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" 356 | ], 357 | "markers": "python_version >= '3.7'", 358 | "version": "==2.0.0" 359 | }, 360 | "rich": { 361 | "hashes": [ 362 | "sha256:0eb63013630c6ee1237e0e395d51cb23513de6b5531235e33889e8842bdf3a6f", 363 | "sha256:7e8700cda776337036a712ff0495b04052fb5f957c7dfb8df997f88350044b64" 364 | ], 365 | "markers": "python_full_version >= '3.6.3' and python_full_version < '4.0.0'", 366 | "version": "==12.3.0" 367 | }, 368 | "setuptools": { 369 | "hashes": [ 370 | "sha256:26ead7d1f93efc0f8c804d9fafafbe4a44b179580a7105754b245155f9af05a8", 371 | "sha256:47c7b0c0f8fc10eec4cf1e71c6fdadf8decaa74ffa087e68cd1c20db7ad6a592" 372 | ], 373 | "markers": "python_version >= '3.7'", 374 | "version": "==62.1.0" 375 | }, 376 | "six": { 377 | "hashes": [ 378 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 379 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 380 | ], 381 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 382 | "version": "==1.16.0" 383 | }, 384 | "toml": { 385 | "hashes": [ 386 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 387 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 388 | ], 389 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 390 | "version": "==0.10.2" 391 | }, 392 | "tomli": { 393 | "hashes": [ 394 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 395 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 396 | ], 397 | "markers": "python_version >= '3.7'", 398 | "version": "==2.0.1" 399 | }, 400 | "tox": { 401 | "hashes": [ 402 | "sha256:0805727eb4d6b049de304977dfc9ce315a1938e6619c3ab9f38682bb04662a5a", 403 | "sha256:37888f3092aa4e9f835fc8cc6dadbaaa0782651c41ef359e3a5743fcb0308160" 404 | ], 405 | "index": "pypi", 406 | "version": "==3.25.0" 407 | }, 408 | "twine": { 409 | "hashes": [ 410 | "sha256:6f7496cf14a3a8903474552d5271c79c71916519edb42554f23f42a8563498a9", 411 | "sha256:817aa0c0bdc02a5ebe32051e168e23c71a0608334e624c793011f120dbbc05b7" 412 | ], 413 | "index": "pypi", 414 | "version": "==4.0.0" 415 | }, 416 | "urllib3": { 417 | "hashes": [ 418 | "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", 419 | "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" 420 | ], 421 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 422 | "version": "==1.26.9" 423 | }, 424 | "virtualenv": { 425 | "hashes": [ 426 | "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a", 427 | "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5" 428 | ], 429 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 430 | "version": "==20.14.1" 431 | }, 432 | "virtualenv-clone": { 433 | "hashes": [ 434 | "sha256:418ee935c36152f8f153c79824bb93eaf6f0f7984bae31d3f48f350b9183501a", 435 | "sha256:44d5263bceed0bac3e1424d64f798095233b64def1c5689afa43dc3223caf5b0" 436 | ], 437 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 438 | "version": "==0.5.7" 439 | }, 440 | "webencodings": { 441 | "hashes": [ 442 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 443 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 444 | ], 445 | "version": "==0.5.1" 446 | }, 447 | "wheel": { 448 | "hashes": [ 449 | "sha256:4bdcd7d840138086126cd09254dc6195fb4fc6f01c050a1d7236f2630db1d22a", 450 | "sha256:e9a504e793efbca1b8e0e9cb979a249cf4a0a7b5b8c9e8b65a5e39d49529c1c4" 451 | ], 452 | "index": "pypi", 453 | "version": "==0.37.1" 454 | }, 455 | "zipp": { 456 | "hashes": [ 457 | "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad", 458 | "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099" 459 | ], 460 | "markers": "python_version >= '3.7'", 461 | "version": "==3.8.0" 462 | } 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pipenv run python setup.py sdist bdist_wheel 4 | pipenv run twine upload dist/* 5 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Twitch Python 2 | 3 | [![Discord](https://user-images.githubusercontent.com/7288322/34471967-1df7808a-efbb-11e7-9088-ed0b04151291.png)](https://discord.gg/wZJFeXC) 4 | [![Build Status](https://api.travis-ci.org/PetterKraabol/Twitch-Python.svg?branch=master)](https://travis-ci.org/PetterKraabol/Twitch-Python) 5 | 6 | 7 | `pip install --user twitch-python` 8 | 9 | An object-oriented approach to accessing the Twitch API and live chat with relationships and smart caching. 10 | 11 | ### Requirements 12 | 13 | * [Python 3.10 or newer](https://www.python.org/downloads/) 14 | * [A Twitch client ID](https://dev.twitch.tv/console/apps) 15 | 16 | ### Usage 17 | 18 | ```python 19 | # Twitch API 20 | 21 | import twitch 22 | 23 | helix = twitch.Helix('client-id', 'client-secret') 24 | ``` 25 | 26 | ```python 27 | # Users 28 | 29 | for user in helix.users(['sodapoppin', 'reckful', 24250859]): 30 | print(user.display_name) 31 | 32 | 33 | print(helix.user('zarlach').display_name) 34 | ``` 35 | 36 | ```python 37 | # Videos 38 | 39 | for video in helix.videos([318017128, 317650435]): 40 | print(video.title) 41 | 42 | 43 | print(helix.video(318017128).title) 44 | ``` 45 | 46 | ```python 47 | # Video Comments (VOD chat) 48 | 49 | for comment in helix.video(318017128).comments: 50 | print(comment.commenter.display_name) 51 | 52 | 53 | for video, comments in helix.videos([318017128, 317650435]).comments: 54 | for comment in comments: 55 | print(comment.commenter.display_name, comment.message.body) 56 | 57 | 58 | for video, comments in helix.user('sodapoppin').videos().comments: 59 | for comment in comments: 60 | print(comment.commenter.display_name, comment.message.body) 61 | 62 | 63 | for user, videos in helix.users(['sodapoppin', 'reckful']).videos(first=5): 64 | for video, comments in videos.comments: 65 | for comment in comments: 66 | print(comment.commenter.display_name, comment.message.body) 67 | ``` 68 | 69 | ```python 70 | # Twitch Chat 71 | 72 | twitch.Chat(channel='#sodapoppin', nickname='zarlach', oauth='oauth:xxxxxx').subscribe( 73 | lambda message: print(message.channel, message.user.display_name, message.text)) 74 | ``` 75 | 76 | ### Features 77 | - Object-oriented relationships 78 | - Smart caching 79 | - New Twitch API (Helix) 80 | - VOD chat from Twitch API v5 81 | 82 | --- 83 | 84 | [Documentation](https://github.com/PetterKraabol/Twitch-Python/wiki) • [Twitch API](https://dev.twitch.tv/docs/) • [Twitch-Chat-Downloader](https://github.com/PetterKraabol/Twitch-Chat-Downloader) 85 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | from setuptools import setup, find_packages 6 | 7 | this_directory = os.path.abspath(os.path.dirname(__file__)) 8 | with open(os.path.join(this_directory, 'readme.md'), encoding='utf-8') as f: 9 | readme = f.read() 10 | 11 | requirements = ['requests', 'rx>=3.0.0'] 12 | test_requirements = ['pipenv', 'twine', 'pytest-cov', 'pytest', 'responses', 'wheel', 'tox'] 13 | setup_requirements = ['pipenv', 'setuptools'] 14 | 15 | setup( 16 | author='Petter Kraabøl', 17 | author_email='petter.zarlach@gmail.com', 18 | classifiers=[ 19 | 'Development Status :: 2 - Pre-Alpha', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Natural Language :: English', 23 | 'Programming Language :: Python :: 3.7', 24 | 'Programming Language :: Python :: 3.8', 25 | ], 26 | description='Twitch module for Python', 27 | install_requires=requirements, 28 | license='MIT', 29 | long_description=readme, 30 | long_description_content_type='text/markdown', 31 | include_package_data=True, 32 | keywords='Twitch API', 33 | name='twitch-python', 34 | packages=find_packages(), 35 | python_requires=">=3.7", 36 | setup_requires=setup_requirements, 37 | test_suite='tests', 38 | tests_require=test_requirements, 39 | url='https://github.com/PetterKraabol/Twitch-Python', 40 | version='0.0.20', 41 | zip_safe=True, 42 | ) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_helix_user import TestHelixUser 2 | from .test_helix_video import TestHelixVideo 3 | from .test_twitch import TestTwitchPython 4 | -------------------------------------------------------------------------------- /tests/test_helix_user.py: -------------------------------------------------------------------------------- 1 | import tracemalloc 2 | import unittest 3 | from typing import Dict, Any 4 | 5 | import responses 6 | 7 | import twitch 8 | 9 | tracemalloc.start() 10 | 11 | 12 | class TestHelixUser(unittest.TestCase): 13 | user: Dict[str, Dict[str, Any]] = { 14 | 'zarlach': { 15 | 'id': '24250859', 16 | 'login': 'zarlach', 17 | 'display_name': 'Zarlach', 18 | 'type': '', 19 | 'broadcaster_type': '', 20 | 'description': '', 21 | 'profile_image_url': 'https://static-cdn.jtvnw.net/jtv_user_pictures/zarlach-profile_image-1cb98e7eadb5918a-300x300.png', 22 | 'offline_image_url': 'https://static-cdn.jtvnw.net/jtv_user_pictures/zarlach-channel_offline_image-f2d036ac9582d793-1920x1080.png', 23 | 'view_count': 1664 24 | }, 25 | 'sodapoppin': { 26 | 'id': '26301881', 27 | 'login': 'sodapoppin', 28 | 'display_name': 'sodapoppin', 29 | 'type': '', 30 | 'broadcaster_type': 'partner', 31 | 'description': 'Wtf do i write here? Click my stream, or i scream.', 32 | 'profile_image_url': 'https://static-cdn.jtvnw.net/jtv_user_pictures/sodapoppin-profile_image-10049b6200f90c14-300x300.png', 33 | 'offline_image_url': 'https://static-cdn.jtvnw.net/jtv_user_pictures/7ed72b04-897e-4a85-a3c9-2a8ba74aeaa7-channel_offline_image-1920x1080.jpg', 34 | 'view_count': 276164750 35 | } 36 | } 37 | 38 | def setUp(self) -> None: 39 | responses.add(responses.GET, 'https://id.twitch.tv/oauth2/token?client_token=id&client_secret=secret&grant_type=client_credentials', 40 | match_querystring=True, 41 | json={'access_token': 'token'}) 42 | 43 | responses.add(responses.GET, 'https://api.twitch.tv/helix/users?login=zarlach', 44 | match_querystring=True, 45 | json={'data': [TestHelixUser.user['zarlach']]}) 46 | responses.add(responses.GET, 'https://api.twitch.tv/helix/users?login=sodapoppin', 47 | match_querystring=True, 48 | json={'data': [TestHelixUser.user['sodapoppin']]}) 49 | 50 | responses.add(responses.GET, 'https://api.twitch.tv/helix/users?id=24250859&login=sodapoppin', 51 | match_querystring=True, 52 | json={'data': [TestHelixUser.user['zarlach'], TestHelixUser.user['sodapoppin']]}) 53 | 54 | responses.add(responses.GET, 'https://api.twitch.tv/helix/users?login=sodapoppin&login=zarlach', 55 | match_querystring=True, 56 | json={'data': [TestHelixUser.user['zarlach'], TestHelixUser.user['sodapoppin']]}) 57 | 58 | @responses.activate 59 | def test_user(self): 60 | helix = twitch.Helix(client_id='id', bearer_token='token', use_cache=True) 61 | 62 | # Get display name to display name 63 | self.assertEqual(helix.user('zarlach').display_name, 'Zarlach') 64 | 65 | @responses.activate 66 | def test_users(self): 67 | # Should returned cached data from above 68 | helix = twitch.Helix(client_id='id', bearer_token='token', use_cache=True) 69 | 70 | for user, display_name in zip(helix.users([24250859, 'sodapoppin']), ['Zarlach', 'sodapoppin']): 71 | self.assertEqual(user.display_name, display_name) 72 | 73 | @responses.activate 74 | def test_custom_user_cache(self): 75 | helix = twitch.Helix(client_id='id', bearer_token='token', use_cache=True) 76 | helix.users(['zarlach', 'sodapoppin']) 77 | 78 | # Users have custom caching, such that url should not be cached 79 | self.assertFalse(helix.api.SHARED_CACHE.has('GET:https://api.twitch.tv/helix/users?login=zarlach')) 80 | 81 | # Cache entries by login name and id numberit 82 | self.assertTrue(helix.api.SHARED_CACHE.has('helix.users.login.zarlach')) 83 | self.assertTrue(helix.api.SHARED_CACHE.has('helix.users.id.24250859')) 84 | 85 | self.assertTrue(helix.api.SHARED_CACHE.has('helix.users.login.sodapoppin')) 86 | self.assertTrue(helix.api.SHARED_CACHE.has('helix.users.id.26301881')) 87 | 88 | # Flush cache to remove 89 | helix.api.flush_cache() 90 | 91 | # Check cache flush 92 | self.assertFalse(helix.api.SHARED_CACHE.has('helix.users.login.zarlach')) 93 | self.assertFalse(helix.api.SHARED_CACHE.has('helix.users.id.24250859')) 94 | 95 | self.assertFalse(helix.api.SHARED_CACHE.has('helix.users.login.sodapoppin')) 96 | self.assertFalse(helix.api.SHARED_CACHE.has('helix.users.id.26301881')) 97 | -------------------------------------------------------------------------------- /tests/test_helix_video.py: -------------------------------------------------------------------------------- 1 | import tracemalloc 2 | import unittest 3 | from typing import Dict, Any 4 | 5 | import responses 6 | 7 | import twitch 8 | from .test_helix_user import TestHelixUser 9 | 10 | tracemalloc.start() 11 | 12 | 13 | class TestHelixVideo(unittest.TestCase): 14 | video: Dict[str, Dict[str, Any]] = { 15 | '471855782': { 16 | 'id': '471855782', 17 | 'user_id': '26301881', 18 | 'user_name': 'sodapoppin', 19 | 'title': '2 days til Classic, passing the time til then. ', 20 | 'description': '', 21 | 'created_at': '2019-08-24T21:34:03Z', 22 | 'published_at': '2019-08-24T21:34:03Z', 23 | 'url': 'https://www.twitch.tv/videos/471855782', 24 | 'thumbnail_url': '', 25 | 'viewable': 'public', 26 | 'view_count': 329, 27 | 'language': 'en', 28 | 'type': 'archive', 29 | 'duration': '2h14m55s' 30 | }, 31 | '471295896': { 32 | 'id': '471295896', 33 | 'user_id': '26301881', 34 | 'user_name': 'sodapoppin', 35 | 'title': '3 days til Classic, passing the time til then. ', 36 | 'description': '', 37 | 'created_at': '2019-08-23T18:40:05Z', 38 | 'published_at': '2019-08-23T18:40:05Z', 39 | 'url': 'https://www.twitch.tv/videos/471295896', 40 | 'thumbnail_url': 'https://static-cdn.jtvnw.net/s3_vods/7d5ae2c2918cf4ca8579_sodapoppin_35403289856_1281380185/thumb/thumb0-%{width}x%{height}.jpg', 41 | 'viewable': 'public', 42 | 'view_count': 5892, 43 | 'language': 'en', 44 | 'type': 'archive', 45 | 'duration': '6h4m13s'} 46 | } 47 | 48 | def setUp(self) -> None: 49 | responses.add(responses.GET, 'https://id.twitch.tv/oauth2/token?client_token=id&client_secret=secret&grant_type=client_credentials', 50 | match_querystring=True, 51 | json={'access_token': 'token'}) 52 | 53 | responses.add(responses.GET, 'https://api.twitch.tv/helix/videos?id=471855782', 54 | match_querystring=True, 55 | json={ 56 | 'data': [TestHelixVideo.video['471855782']], 57 | 'pagination': {'cursor': 'eyJiIjpudWxsLCJhIjp7Ik9mZnNldCI6MX19'} 58 | }) 59 | responses.add(responses.GET, 'https://api.twitch.tv/helix/users?login=sodapoppin', 60 | match_querystring=True, 61 | json={'data': [TestHelixUser.user['sodapoppin']]}) 62 | 63 | responses.add(responses.GET, 'https://api.twitch.tv/helix/videos?user_id=26301881&first=2', 64 | match_querystring=True, 65 | json={ 66 | 'data': [TestHelixVideo.video['471855782'], TestHelixVideo.video['471295896']], 67 | 'pagination': {'cursor': 'eyJiIjpudWxsLCJhIjp7Ik9mZnNldCI6Mn19'} 68 | }) 69 | 70 | @responses.activate 71 | def test_video(self): 72 | helix = twitch.Helix(client_id='id', bearer_token='token', use_cache=True) 73 | 74 | # Get display name to display name 75 | self.assertEqual(helix.video('471855782').user_name, 'sodapoppin') 76 | 77 | @responses.activate 78 | def test_first_videos(self): 79 | # Should returned cached data from above 80 | helix = twitch.Helix(client_id='id', bearer_token='token', use_cache=True) 81 | 82 | for video, user_name in zip(helix.user('sodapoppin').videos(first=2), ['sodapoppin', 'sodapoppin']): 83 | self.assertEqual(video.user_name, user_name) 84 | 85 | @responses.activate 86 | def test_custom_video_cache(self): 87 | helix = twitch.Helix(client_id='id', bearer_token='token', use_cache=True) 88 | _ = helix.video(471855782) 89 | 90 | # Videos have custom caching, such that url should not be cached 91 | self.assertFalse(helix.api.SHARED_CACHE.has('GET:https://api.twitch.tv/helix/videos?id=471855782')) 92 | 93 | # Cache entries by video id 94 | self.assertTrue(helix.api.SHARED_CACHE.has('helix.video.471855782')) 95 | 96 | # Flush cache to remove 97 | helix.api.flush_cache() 98 | 99 | # Check cache flush (cache should be empty) 100 | self.assertFalse(helix.api.SHARED_CACHE.has('helix.video.471855782')) 101 | -------------------------------------------------------------------------------- /tests/test_twitch.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import timedelta 3 | 4 | import twitch 5 | 6 | 7 | class TestTwitchPython(unittest.TestCase): 8 | 9 | def test_cache(self): 10 | helix = twitch.Helix(client_id='id', bearer_token='token', use_cache=True) 11 | 12 | helix.api.SHARED_CACHE.set('key', {'data': 'value'}) 13 | 14 | # Entry in shared cache 15 | self.assertTrue(helix.api.SHARED_CACHE.has('key')) 16 | 17 | # Not expired 18 | self.assertFalse(helix.api.SHARED_CACHE.expired('key')) 19 | 20 | # Expiration 21 | helix.api.SHARED_CACHE.set('key-with-expiration', {'data': 'value'}, duration=timedelta(seconds=-1)) 22 | 23 | # Key is expired 24 | self.assertTrue(helix.api.SHARED_CACHE.expired('key-with-expiration')) 25 | 26 | # Unable to retrieve expired value 27 | self.assertFalse(helix.api.SHARED_CACHE.get('key-with-expiration')) 28 | 29 | # Has key, but expired 30 | self.assertTrue(helix.api.SHARED_CACHE.has('key-with-expiration')) 31 | 32 | # Clean expired keys 33 | helix.api.SHARED_CACHE.clean() 34 | self.assertFalse(helix.api.SHARED_CACHE.has('key-with-expiration')) 35 | 36 | # Flush cache 37 | helix.api.SHARED_CACHE.flush() 38 | self.assertFalse(helix.api.SHARED_CACHE.has('key')) 39 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38 3 | 4 | [testenv] 5 | deps = 6 | rx 7 | pytest 8 | pytest-cov 9 | requests 10 | responses 11 | 12 | commands = 13 | pytest --cov=twitch 14 | -------------------------------------------------------------------------------- /twitch/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from twitch.chat import Chat 4 | from twitch.helix import Helix 5 | from twitch.tmi import TMI 6 | from twitch.v5 import V5 7 | 8 | name: str = "twitch" 9 | 10 | __all__: List[Callable] = [ 11 | Helix, 12 | V5, 13 | TMI, 14 | Chat, 15 | ] 16 | -------------------------------------------------------------------------------- /twitch/api.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import timedelta 3 | from typing import Dict, Any, Optional 4 | 5 | import requests 6 | 7 | from .cache import Cache 8 | 9 | 10 | class API: 11 | SHARED_CACHE: Cache = Cache() 12 | 13 | def __init__(self, 14 | base_url: Optional[str] = None, 15 | client_id: Optional[str] = None, 16 | client_secret: Optional[str] = None, 17 | use_cache: Optional[bool] = False, 18 | request_rate: Optional[int] = None, 19 | bearer_token: Optional[str] = None, 20 | handle_rate_limit: bool = True, 21 | cache_duration: Optional[timedelta] = None): 22 | """ 23 | Twitch API 24 | :param base_url: API URL 25 | :param client_id: Twitch Client ID 26 | :param use_cache: Use local API cache 27 | :param bearer_token: Twitch bearer token 28 | :param handle_rate_limit: Handle rate limits by sleeping 29 | :param cache_duration: Local cache duration 30 | """ 31 | self.base_url: Optional[str] = base_url 32 | self.client_id: Optional[str] = client_id 33 | self.client_secret: Optional[str] = client_secret 34 | self.use_cache: bool = use_cache 35 | self.request_rate: Optional[int] = request_rate 36 | self.bearer_token: Optional[str] = bearer_token 37 | self.handle_rate_limit: bool = handle_rate_limit 38 | self.cache_duration: Optional[timedelta] = cache_duration 39 | 40 | # Rate limit 41 | self.rate_limit_points: int = 800 if self.bearer_token else 30 42 | self.rate_limit_remaining: int = self.rate_limit_points 43 | self.rate_limit_reset: int = 0 44 | 45 | def _headers(self, custom: Dict[str, str] = None) -> Dict[str, str]: 46 | default: Dict[str, str] = {} 47 | 48 | if self.client_id: 49 | default['Client-ID'] = self.client_id 50 | 51 | if self.bearer_token: 52 | default['Authorization'] = self.bearer_token 53 | 54 | return {**default, **custom} if custom else default.copy() 55 | 56 | def _url(self, path: str = '') -> str: 57 | return self.base_url.rstrip('/') + '/' + path.lstrip('/') 58 | 59 | @staticmethod 60 | def flush_cache(): 61 | API.SHARED_CACHE.flush() 62 | 63 | def _handle_rate_limit(self) -> None: 64 | if self.handle_rate_limit and self.rate_limit_remaining == 0: 65 | time_to_sleep: float = min((self.rate_limit_reset - time.time()), 10) 66 | time_to_sleep = max(time_to_sleep, 1) 67 | 68 | time.sleep(time_to_sleep) 69 | 70 | def _set_rate_limit(self, response: requests.Response) -> None: 71 | # Update rate limit fields 72 | if 'Ratelimit-Limit' in response.headers.keys(): 73 | self.rate_limit_points: int = int(response.headers.get('Ratelimit-Limit')) 74 | self.rate_limit_remaining: int = int(response.headers.get('Ratelimit-Remaining')) 75 | self.rate_limit_reset: int = int(response.headers.get('Ratelimit-Reset')) 76 | 77 | def request(self, method, path: str = '', ignore_cache: bool = False, **kwargs) -> dict: 78 | url: str = self._url(path=path) 79 | request = requests.Request(method, url, **kwargs).prepare() 80 | cache_key: str = f'{method}:{request.url}' 81 | 82 | # Cache lookup 83 | if self.use_cache and not ignore_cache and API.SHARED_CACHE.get(cache_key): 84 | return API.SHARED_CACHE.get(cache_key) 85 | 86 | # Check rate limit 87 | self._handle_rate_limit() 88 | 89 | while True: 90 | response = requests.Session().send(request) 91 | self._set_rate_limit(response) 92 | 93 | # Too many requests status 94 | if response.status_code == 429 and self.handle_rate_limit: 95 | self._handle_rate_limit() 96 | else: 97 | break 98 | 99 | # Raise exception if status code is not 200 100 | response.raise_for_status() 101 | 102 | # Cache response 103 | if self.use_cache and not ignore_cache: 104 | API.SHARED_CACHE.set(key=cache_key, value=response.json(), duration=self.cache_duration) 105 | 106 | return response.json() 107 | 108 | def get(self, path: str, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, Any]] = None, 109 | ignore_cache: bool = False, 110 | **kwargs) -> dict: 111 | return self.request('GET', path, ignore_cache, params=params, headers=self._headers(headers), **kwargs) 112 | 113 | def post(self): 114 | pass 115 | 116 | def put(self): 117 | pass 118 | -------------------------------------------------------------------------------- /twitch/baseresource.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic, List, Optional 2 | 3 | from .api import API 4 | 5 | T = TypeVar('T') 6 | 7 | 8 | class BaseResource(Generic[T]): 9 | FIRST_API_LIMIT: int = 100 10 | 11 | def __init__(self, path: str, api: API, data: Optional[List[T]] = None, **kwargs): 12 | self._path: str = path 13 | self._api: Optional[API] = api 14 | self._data: List[T] = data or [] 15 | self._kwargs: dict = kwargs 16 | 17 | def __iter__(self): 18 | for item in self._data: 19 | yield item 20 | 21 | def __getitem__(self, index: int) -> T: 22 | return self._data[index] 23 | -------------------------------------------------------------------------------- /twitch/cache.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, datetime 2 | from typing import Dict, Optional 3 | 4 | 5 | class Cache: 6 | EXPIRATION_FIELD: str = "CACHE_EXPIRATION" 7 | 8 | def __init__(self, duration: Optional[timedelta] = None): 9 | self._store: Dict[str, dict] = {} 10 | self._duration: timedelta = duration or timedelta(minutes=30) 11 | 12 | def get(self, key: str, ignore_expiration: bool = False) -> Optional[dict]: 13 | if self.has(key) and (ignore_expiration or not self.expired(key)): 14 | return self._store[key]['value'] 15 | else: 16 | return None 17 | 18 | def set(self, key: str, value: dict, duration: Optional[timedelta] = None) -> datetime: 19 | expiration: datetime = datetime.now() + (duration or self._duration) 20 | 21 | self._store[key] = {**{'value': value}, **{f'{Cache.EXPIRATION_FIELD}': expiration}} 22 | return expiration 23 | 24 | def has(self, key: str) -> bool: 25 | return key in self._store.keys() 26 | 27 | def expired(self, key: str) -> bool: 28 | return self.has(key) and self._store[key][Cache.EXPIRATION_FIELD] < datetime.now() 29 | 30 | def set_expiration(self, key: str, expiration: datetime) -> None: 31 | if self.has(key): 32 | self._store[key][Cache.EXPIRATION_FIELD] = expiration 33 | 34 | def extend(self, key: str, duration: timedelta) -> Optional[datetime]: 35 | if self.has(key): 36 | self._store[key][Cache.EXPIRATION_FIELD] += duration 37 | return self._store[key][Cache.EXPIRATION_FIELD] 38 | 39 | def flush(self) -> None: 40 | self._store.clear() 41 | 42 | def remove(self, key) -> None: 43 | if self.has(key): 44 | del self._store[key] 45 | 46 | def clean(self) -> None: 47 | [self.remove(key) for key in list(self._store.keys()) if self.expired(key)] 48 | -------------------------------------------------------------------------------- /twitch/chat/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from .chat import Chat 4 | from .irc import IRC 5 | from .message import Message 6 | 7 | __all__: List[Callable] = [ 8 | Chat, 9 | IRC, 10 | Message, 11 | ] 12 | -------------------------------------------------------------------------------- /twitch/chat/chat.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Optional 3 | 4 | from rx.subject import Subject 5 | 6 | import twitch 7 | import twitch.chat as chat 8 | 9 | 10 | class Chat(Subject): 11 | 12 | def __init__(self, channel: str, nickname: str, oauth: str, helix: Optional['twitch.Helix'] = None): 13 | """ 14 | :param channel: Channel name 15 | :param nickname: User nickname 16 | :param oauth: Twitch OAuth 17 | :param helix: Optional Helix API 18 | """ 19 | super().__init__() 20 | self.helix: Optional['twitch.Helix'] = helix 21 | 22 | self.irc = chat.IRC(nickname, password=oauth) 23 | self.irc.incoming.subscribe(self._message_handler) 24 | self.irc.start() 25 | 26 | self.channel = channel.lstrip('#') 27 | self.joined: bool = False 28 | 29 | def _message_handler(self, data: bytes) -> None: 30 | # First messages are server connection messages, 31 | # which should be handled by joining the chat room. 32 | if not self.joined: 33 | self.irc.join_channel(self.channel) 34 | self.joined = True 35 | 36 | text = data.decode("UTF-8").strip('\n\r') 37 | 38 | if text.find('PRIVMSG') >= 0: 39 | sender = text.split('!', 1)[0][1:] 40 | message = text.split('PRIVMSG', 1)[1].split(':', 1)[1] 41 | self.on_next( 42 | chat.Message(channel=self.channel, sender=sender, text=message, helix_api=self.helix, chat=self)) 43 | 44 | def send(self, message: str) -> None: 45 | while not self.joined: 46 | time.sleep(0.01) 47 | self.irc.send_message(message=message, channel=self.channel) 48 | 49 | def __del__(self): 50 | self.irc.active = False 51 | self.dispose() 52 | -------------------------------------------------------------------------------- /twitch/chat/irc.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | import threading 4 | from typing import List 5 | 6 | from rx.subject import Subject 7 | 8 | 9 | class IRC(threading.Thread): 10 | 11 | def __init__(self, nickname: str, password: str, address: str = 'irc.chat.twitch.tv', port: int = 6667): 12 | super().__init__() 13 | 14 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 15 | self.address: str = address 16 | self.port: int = port 17 | self.channels: List[str] = [] 18 | self.nickname: str = nickname 19 | self.password: str = 'oauth:' + password.lstrip('oauth:') 20 | self.active: bool = True 21 | self.incoming: Subject = Subject() 22 | 23 | def run(self): 24 | self.connect() 25 | self.authenticate() 26 | 27 | while self.active: 28 | try: 29 | data = self._read_line() 30 | text = data.decode("UTF-8").strip('\n\r') 31 | 32 | if text.find('PING') >= 0: 33 | self.send_raw('PONG ' + text.split()[1]) 34 | 35 | if text.find('Login authentication failed') > 0: 36 | logging.fatal('IRC authentication error: ' + text or '') 37 | return 38 | 39 | # Publish data to subscribers 40 | self.incoming.on_next(data) 41 | 42 | except IOError: 43 | break 44 | 45 | def send_raw(self, message: str) -> None: 46 | data = (message.lstrip('\n') + '\n').encode('utf-8') 47 | self.socket.send(data) 48 | 49 | def send_message(self, message: str, channel: str) -> None: 50 | channel = channel.lstrip('#') 51 | self.send_raw(f'PRIVMSG #{channel} :{message}') 52 | 53 | def connect(self) -> None: 54 | self.socket.connect((self.address, self.port)) 55 | 56 | def authenticate(self) -> None: 57 | self.send_raw(f'PASS {self.password}') 58 | self.send_raw(f'NICK {self.nickname}') 59 | 60 | def join_channel(self, channel: str) -> None: 61 | channel = channel.lstrip('#') 62 | self.channels.append(channel) 63 | self.send_raw(f'JOIN #{channel}') 64 | 65 | def leave_channel(self, channel: str) -> None: 66 | channel = channel.lstrip('#') 67 | self.channels.remove(channel) 68 | self.send_raw(f'PART #{channel}') 69 | 70 | def leave_channels(self, channels: List[str]) -> None: 71 | channels = [channel.lstrip('#') for channel in channels] 72 | [self.channels.remove(channel) for channel in channels] 73 | self.send_raw('PART #' + '#'.join(channels)) 74 | 75 | def _read_line(self) -> bytes: 76 | data: bytes = b'' 77 | while True: 78 | next_byte: bytes = self.socket.recv(1) 79 | if next_byte == b'\n': 80 | break 81 | data += next_byte 82 | 83 | return data 84 | -------------------------------------------------------------------------------- /twitch/chat/message.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from twitch.helix import User, Helix 4 | from .chat import Chat 5 | 6 | 7 | class Message: 8 | 9 | def __init__(self, 10 | channel: str, 11 | sender: str, 12 | text: str, 13 | helix_api: Optional[Helix] = None, 14 | chat: Optional[Chat] = None): 15 | self.channel: str = channel 16 | self.sender: str = sender 17 | self.text: str = text 18 | self.helix: Optional[Helix] = helix_api 19 | self.chat: Optional[Chat] = chat 20 | 21 | @property 22 | def user(self) -> Optional[User]: 23 | return self.helix.user(self.sender) if self.helix else None 24 | -------------------------------------------------------------------------------- /twitch/helix/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from twitch.helix.helix import Helix 4 | from twitch.helix.models import Clip, Follow, Game, Stream, User, Video 5 | from twitch.helix.resources import Clips, Follows, Games, Streams, StreamNotFound, Users, Videos 6 | 7 | __all__: List[Callable] = [ 8 | Helix, 9 | Stream, StreamNotFound, Streams, 10 | Video, Videos, 11 | User, Users, 12 | Follow, Follows, 13 | Game, Games, 14 | ] 15 | -------------------------------------------------------------------------------- /twitch/helix/helix.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import List, Union, Optional 3 | 4 | import requests 5 | 6 | import twitch.helix as helix 7 | from twitch.api import API 8 | 9 | 10 | class Helix: 11 | BASE_URL: str = 'https://api.twitch.tv/helix/' 12 | 13 | def __init__(self, 14 | client_id: str, 15 | client_secret: str = None, 16 | use_cache: bool = False, 17 | cache_duration: Optional[timedelta] = None, 18 | handle_rate_limit: bool = True, 19 | bearer_token: Optional[str] = None): 20 | """ 21 | Helix API (New Twitch API) 22 | https://dev.twitch.tv/docs/api/ 23 | 24 | :param client_id: Twitch client ID 25 | :param client_secret: Twitch client secret 26 | :param use_cache: Cache API requests (recommended) 27 | :param cache_duration: Cache duration 28 | :param bearer_token: API bearer token 29 | """ 30 | self.client_secret: str = client_secret 31 | 32 | if bearer_token is None: 33 | if client_id is None and client_secret is None: 34 | print("Missing Twitch client id secret") 35 | 36 | bearer_token = requests.post(f"https://id.twitch.tv/oauth2/token" 37 | f"?client_id={client_id}" 38 | f"&client_secret={client_secret}" 39 | "&grant_type=client_credentials").json()['access_token'] 40 | 41 | if bearer_token.lower().startswith('bearer'): 42 | bearer_token = bearer_token[6:0] 43 | 44 | self.api = API(Helix.BASE_URL, 45 | client_id=client_id, 46 | client_secret=client_secret, 47 | use_cache=use_cache, 48 | cache_duration=cache_duration, 49 | handle_rate_limit=handle_rate_limit, 50 | bearer_token='Bearer ' + bearer_token.lower().strip()) 51 | 52 | def users(self, *args) -> 'helix.Users': 53 | return helix.Users(self.api, *args) 54 | 55 | def user(self, user: Union[str, int]) -> 'helix.User': 56 | return self.users(user)[0] 57 | 58 | def videos(self, video_ids: Union[str, int, List[Union[str, int]]] = None, **kwargs) -> 'helix.Videos': 59 | if video_ids and type(video_ids) != list: 60 | video_ids = [int(video_ids)] 61 | return helix.Videos(self.api, video_ids=video_ids, **kwargs) 62 | 63 | def video(self, video_id: Union[str, int] = None, **kwargs) -> 'helix.Video': 64 | if video_id: 65 | kwargs['id'] = [video_id] 66 | return helix.Videos(self.api, video_ids=None, **kwargs)[0] 67 | 68 | def streams(self, **kwargs) -> 'helix.Streams': 69 | return helix.Streams(self.api, **kwargs) 70 | 71 | def stream(self, **kwargs) -> 'helix.Stream': 72 | return self.streams(**kwargs)[0] 73 | 74 | def games(self, **kwargs) -> 'helix.Games': 75 | return helix.Games(self.api, **kwargs) 76 | 77 | def game(self, **kwargs) -> 'helix.Game': 78 | return self.games(**kwargs)[0] 79 | 80 | def top_games(self, **kwargs) -> List['helix.Game']: 81 | return helix.Games(self.api).top(**kwargs) 82 | 83 | def top_game(self) -> 'helix.Game': 84 | return self.top_games()[0] 85 | -------------------------------------------------------------------------------- /twitch/helix/models/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from .clip import Clip 4 | from .follow import Follow 5 | from .game import Game 6 | from .model import Model 7 | from .stream import Stream 8 | from .user import User 9 | from .video import Video 10 | 11 | __all__: List[Callable] = [ 12 | Clip, 13 | Follow, 14 | Game, 15 | Model, 16 | Stream, 17 | User, 18 | Video, 19 | ] 20 | -------------------------------------------------------------------------------- /twitch/helix/models/clip.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | from twitch.api import API 4 | from .model import Model 5 | 6 | 7 | class Clip(Model): 8 | 9 | def __init__(self, api: API, data: Dict[str, Any]): 10 | super().__init__(api, data) 11 | -------------------------------------------------------------------------------- /twitch/helix/models/follow.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .model import Model 6 | 7 | 8 | class Follow(Model): 9 | 10 | def __init__(self, api: API, data: Dict[str, Any]): 11 | super().__init__(api, data) 12 | 13 | self.from_id: str = data.get('from_id') 14 | self.from_name: str = data.get('from_name') 15 | self.to_id: str = data.get('to_id') 16 | self.to_name: str = data.get('to_name') 17 | self.followed_at: str = data.get('followed_at') 18 | 19 | @property 20 | def follower(self) -> 'helix.User': 21 | """ 22 | This user follows the followed 23 | :return: User following the user 24 | """ 25 | return helix.Users(self._api, int(self.from_id))[0] 26 | 27 | @property 28 | def followed(self) -> 'helix.User': 29 | """ 30 | This user is being followed by the follower 31 | :return: User being followed 32 | """ 33 | return helix.Users(self._api, int(self.to_id))[0] 34 | -------------------------------------------------------------------------------- /twitch/helix/models/game.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .model import Model 6 | 7 | 8 | class Game(Model): 9 | 10 | def __init__(self, api: API, data: Dict[str, Any]): 11 | super().__init__(api, data) 12 | 13 | self.id: str = data.get('id') 14 | self.name: str = data.get('name') 15 | self.box_art_url: str = data.get('box_art_url') 16 | 17 | def __str__(self): 18 | return self.name 19 | 20 | def videos(self, **kwargs) -> 'helix.Videos': 21 | return helix.Videos(self._api, game_id=self.id, **kwargs) 22 | -------------------------------------------------------------------------------- /twitch/helix/models/model.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from dataclasses import dataclass 3 | from typing import Optional, Dict, Any 4 | 5 | from twitch.api import API 6 | 7 | 8 | @dataclass 9 | class Model(metaclass=ABCMeta): 10 | _api: Optional[API] 11 | data: Dict[str, Any] 12 | -------------------------------------------------------------------------------- /twitch/helix/models/stream.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Any 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .model import Model 6 | 7 | 8 | class Stream(Model): 9 | 10 | def __init__(self, api: API, data: Dict[str, Any]): 11 | super().__init__(api, data) 12 | 13 | self.id: str = data.get('id') 14 | self.user_id: str = data.get('user_id') 15 | self.game_id: str = data.get('game_id') 16 | self.community_ids: List[str] = data.get('community_ids', []) 17 | self.type: str = data.get('type') 18 | self.title: str = data.get('title') 19 | self.viewer_count: int = data.get('viewer_count') 20 | self.started_at: str = data.get('started_at') 21 | self.language: str = data.get('language') 22 | self.thumbnail_url: str = data.get('thumbnail_url') 23 | 24 | def __str__(self): 25 | return self.title 26 | 27 | @property 28 | def user(self) -> 'helix.User': 29 | return helix.Users(self._api, int(self.user_id))[0] 30 | -------------------------------------------------------------------------------- /twitch/helix/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | import twitch.helix as helix 4 | import twitch.tmi as tmi 5 | from twitch.api import API 6 | from .model import Model 7 | 8 | 9 | class User(Model): 10 | 11 | def __init__(self, api: API, data: Dict[str, Any]): 12 | super().__init__(api, data) 13 | 14 | self.broadcaster_type: str = data.get('broadcaster_type') 15 | self.description: str = data.get('description') 16 | self.display_name: str = data.get('display_name') 17 | self.email: str = data.get('email') 18 | self.id: str = data.get('id') 19 | self.login: str = data.get('login') 20 | self.offline_image_url: str = data.get('offline_image_url') 21 | self.profile_image_url: str = data.get('profile_image_url') 22 | self.type: str = data.get('type') 23 | self.view_count: int = data.get('view_count') 24 | self.created_at: int = data.get('created_at') 25 | 26 | def __str__(self): 27 | return self.login 28 | 29 | def videos(self, **kwargs) -> 'helix.Videos': 30 | return helix.Videos(api=self._api, user_id=int(self.id), **kwargs) 31 | 32 | @property 33 | def stream(self) -> 'helix.Stream': 34 | return helix.Streams(api=self._api, user_id=int(self.id))[0] 35 | 36 | @property 37 | def is_live(self) -> bool: 38 | try: 39 | if self.stream: 40 | return True 41 | except helix.StreamNotFound: 42 | return False 43 | 44 | @property 45 | def chatters(self) -> 'tmi.Chatters': 46 | source = tmi.TMI('') 47 | source.api = self._api 48 | source.api.base_url = tmi.TMI.BASE_URL 49 | 50 | return source.chatters(self.login) 51 | 52 | def following(self, **kwargs) -> 'helix.Follows': 53 | kwargs['from_id'] = self.id 54 | return helix.Follows(api=self._api, follow_type='followings', **kwargs) 55 | 56 | def followers(self, **kwargs) -> 'helix.Follows': 57 | kwargs['to_id'] = self.id 58 | return helix.Follows(api=self._api, follow_type='followers', **kwargs) 59 | -------------------------------------------------------------------------------- /twitch/helix/models/video.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | import twitch.helix as helix 4 | import twitch.v5 as v5 5 | from twitch.api import API 6 | from .model import Model 7 | 8 | 9 | class Video(Model): 10 | 11 | def __init__(self, api: API, data: Dict[str, Any]): 12 | super().__init__(api, data) 13 | 14 | self.id: str = data.get('id') 15 | self.user_id: str = data.get('user_id') 16 | self.user_name: str = data.get('user_name') 17 | self.title: str = data.get('title') 18 | self.description: str = data.get('description') 19 | self.created_at: str = data.get('created_at') 20 | self.published_at: str = data.get('published_at') 21 | self.url: str = data.get('url') 22 | self.thumbnail_url: str = data.get('thumbnail_url') 23 | self.viewable: str = data.get('viewable') 24 | self.view_count: int = data.get('view_count') 25 | self.language: str = data.get('language') 26 | self.type: str = data.get('type') 27 | self.duration: str = data.get('duration') 28 | 29 | def __str__(self): 30 | return self.title 31 | 32 | @property 33 | def comments(self) -> 'v5.Comments': 34 | return v5.V5.comments(self.id) 35 | 36 | @property 37 | def user(self) -> 'helix.User': 38 | return helix.Users(self._api, int(self.user_id))[0] 39 | -------------------------------------------------------------------------------- /twitch/helix/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from .clips import Clips 4 | from .follows import Follows 5 | from .games import Games 6 | from .resource import Resource 7 | from .streams import Streams, StreamNotFound 8 | from .users import Users 9 | from .videos import Videos 10 | 11 | __all__: List[Callable] = [ 12 | Follows, 13 | Games, 14 | Resource, 15 | Streams, 16 | StreamNotFound, 17 | Users, 18 | Videos, 19 | ] 20 | -------------------------------------------------------------------------------- /twitch/helix/resources/clips.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from twitch.api import API 4 | from twitch.helix.resources.resource import T 5 | from .resource import Resource 6 | 7 | 8 | class Clips(Resource['helix.Clip']): 9 | 10 | def __init__(self, api: API, **kwargs: Optional): 11 | super().__init__(api=api, path='users/follows') 12 | self._kwargs = kwargs 13 | 14 | def _can_paginate(self) -> bool: 15 | return False 16 | 17 | def _handle_pagination_response(self, response: dict) -> List[T]: 18 | pass 19 | -------------------------------------------------------------------------------- /twitch/helix/resources/follows.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .resource import Resource 6 | 7 | 8 | class Follows(Resource['helix.Follow']): 9 | FOLLOWING: int = 1 10 | FOLLOWED: int = 2 11 | 12 | def __init__(self, api: API, follow_type: 1, **kwargs: Optional): 13 | super().__init__(api=api, path='users/follows') 14 | self._kwargs = kwargs 15 | self.follow_type: int = follow_type 16 | 17 | def _can_paginate(self) -> bool: 18 | return True 19 | 20 | def _handle_pagination_response(self, response: dict) -> List['helix.Follow']: 21 | return [helix.Follow(api=self._api, data=follow) for follow in response.get('data', [])] 22 | 23 | @property 24 | def total(self) -> int: 25 | return self._api.get(self._path, params={**self._kwargs, **{'first': 100}}).get('total', -1) 26 | 27 | @property 28 | def users(self) -> 'helix.Users': 29 | user_ids: List[int] = [] 30 | if self.follow_type == 'followers': 31 | user_ids = [int(follow.from_id) for follow in self] 32 | elif self.follow_type == 'followings': 33 | user_ids = [int(follow.to_id) for follow in self] 34 | 35 | return helix.Users(self._api, user_ids) 36 | -------------------------------------------------------------------------------- /twitch/helix/resources/games.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .resource import Resource 6 | 7 | 8 | class Games(Resource['helix.Game']): 9 | 10 | def __init__(self, api: API, **kwargs: Optional): 11 | super().__init__(api=api, path='games') 12 | 13 | if len(kwargs) > 0: 14 | self._data = [helix.Game(api=self._api, data=game) for game in 15 | self._api.get(self._path, params=kwargs)['data']] 16 | 17 | def top(self, **kwargs) -> List['helix.Game']: 18 | return [helix.Game(api=self._api, data=game) for game in 19 | self._api.get(f'{self._path}/top', params=kwargs)['data']] 20 | 21 | def _can_paginate(self) -> bool: 22 | return False 23 | 24 | def _handle_pagination_response(self, response: dict) -> List['helix.Game']: 25 | pass 26 | -------------------------------------------------------------------------------- /twitch/helix/resources/resource.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Optional, List, Generator, Generic, TypeVar 3 | 4 | import requests 5 | 6 | from twitch.api import API 7 | from twitch.baseresource import BaseResource 8 | 9 | T = TypeVar('T') 10 | 11 | 12 | class Resource(BaseResource, Generic[T]): 13 | 14 | def __init__(self, path: str, api: API, data: Optional[List[T]] = None, **kwargs): 15 | super().__init__(path=path, api=api, data=data, **kwargs) 16 | self._cursor: Optional[str] = None 17 | 18 | def __iter__(self) -> Generator[T, None, None]: 19 | # Yield available data 20 | if self._data: 21 | for entry in self._data: 22 | yield entry 23 | return 24 | 25 | # Check if iterator should paginate from api 26 | if self._can_paginate() is False: 27 | return 28 | 29 | # Remaining elements. Download all if set to None 30 | remaining: Optional[int] = self._kwargs.get('first', None) 31 | 32 | # When downloading add data, set first to maximum to minimize api calls 33 | if remaining is None: 34 | self._kwargs['first'] = BaseResource.FIRST_API_LIMIT 35 | 36 | # Paginate 37 | while remaining is None or remaining > 0: 38 | # Update remaining 39 | if remaining: 40 | self._kwargs['first'] = min(Resource.FIRST_API_LIMIT, remaining) 41 | 42 | # API Response 43 | try: 44 | response: dict = self._next_page() 45 | except requests.exceptions.HTTPError: 46 | return 47 | 48 | # Let resource handle pagination response and return elements 49 | elements: List[T] = self._handle_pagination_response(response) 50 | for element in elements: 51 | yield element 52 | 53 | # Decrement remaining if not None 54 | if remaining is not None: 55 | remaining = 0 if len(elements) > remaining else remaining - self._kwargs['first'] 56 | 57 | # If no next cursor, stop 58 | if not self._cursor: 59 | break 60 | 61 | def __getitem__(self, index: int) -> T: 62 | if len(self._data) > index: 63 | return self._data[index] 64 | 65 | for i, value in enumerate(self._data): 66 | if i == index: 67 | return value 68 | 69 | @abstractmethod 70 | def _can_paginate(self) -> bool: 71 | """ 72 | Check if resource can be paginated 73 | :return: Can paginate 74 | """ 75 | return True 76 | 77 | @abstractmethod 78 | def _handle_pagination_response(self, response: dict) -> List[T]: 79 | """ 80 | Pagination hook for every iteration 81 | :param response: Response from pagination 82 | :return: None 83 | """ 84 | elements: List[T] = [T(api=self._api, data=data) for data in response['data']] 85 | 86 | return elements 87 | 88 | def _next_page(self, ignore_cache: bool = False) -> dict: 89 | """ 90 | API Pagination 91 | Get next page from API 92 | :param ignore_cache: Whether to ignore API cache. 93 | :return: Response 94 | """ 95 | # API Response 96 | self._kwargs['after'] = self._cursor 97 | 98 | response: dict = self._api.get(self._path, params=self._kwargs, ignore_cache=ignore_cache) 99 | 100 | # Set pagination cursor 101 | self._cursor = response.get('pagination', {}).get('cursor', None) 102 | 103 | return response 104 | -------------------------------------------------------------------------------- /twitch/helix/resources/streams.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, Tuple, List 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .resource import Resource 6 | 7 | 8 | class StreamNotFound(Exception): 9 | pass 10 | 11 | 12 | class Streams(Resource['helix.Stream']): 13 | 14 | def __init__(self, api: API, **kwargs): 15 | super().__init__(api=api, path='streams') 16 | 17 | response: dict = self._api.get(self._path, params=kwargs) 18 | 19 | if response['data']: 20 | self._data = [helix.Stream(api=self._api, data=video) for video in 21 | self._api.get(self._path, params=kwargs)['data']] 22 | else: 23 | raise StreamNotFound('No stream was found') 24 | 25 | @property 26 | def users(self) -> Generator[Tuple['helix.Stream', 'helix.User'], None, None]: 27 | for stream in self: 28 | yield stream, stream.user 29 | 30 | def _can_paginate(self) -> bool: 31 | return False 32 | 33 | def _handle_pagination_response(self, response: dict) -> List['helix.Stream']: 34 | pass 35 | -------------------------------------------------------------------------------- /twitch/helix/resources/users.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Generator, Tuple, Dict 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .resource import Resource 6 | 7 | 8 | class Users(Resource['helix.User']): 9 | 10 | def __init__(self, api: API, *args): 11 | super().__init__(api=api, path='users') 12 | 13 | # Load data 14 | users: List[Union[str, int]] = [] 15 | for user in args: 16 | users += [user] if type(user) in [str, int] else list(user) 17 | 18 | params: Dict[str, list] = dict() 19 | 20 | # Split user id and login (based on type t) 21 | params['id'], params['login'] = [ 22 | list(set(n)) for n in [ 23 | [str(user) for user in users if type(user) == t] for t in [int, str] 24 | ] 25 | ] 26 | 27 | # todo: Authenticated user if bearer token is provided 28 | if not len(params['id'] + params['login']): 29 | pass 30 | 31 | # Custom user caching 32 | if self._api.use_cache: 33 | cache_hits: Dict[str, list] = {'id': [], 'login': []} 34 | for key, users in tuple(params.items()): 35 | for user in users: 36 | cache_key: str = f'helix.users.{key}.{user}' 37 | cache_data: dict = API.SHARED_CACHE.get(cache_key) 38 | if cache_data: 39 | self._data.append(helix.User(api=self._api, data=cache_data)) 40 | cache_hits[key].append(user) 41 | 42 | # Remove cached users from params 43 | params['id'], params['login'] = [ 44 | [n for n in params[key] if n not in cache_hits[key]] for key in ['id', 'login'] 45 | ] 46 | 47 | # Fetch non-cached users from API 48 | if len(params['id'] + params['login']): 49 | for data in self._api.get(self._path, params=params, ignore_cache=True)['data']: 50 | 51 | # Create and append user) 52 | user = helix.User(api=self._api, data=data) 53 | self._data.append(user) 54 | 55 | # Save to cache 56 | if self._api.use_cache: 57 | API.SHARED_CACHE.set(f'helix.users.login.{user.login}', data) 58 | API.SHARED_CACHE.set(f'helix.users.id.{user.id}', data) 59 | 60 | 61 | def _can_paginate(self) -> bool: 62 | return False 63 | 64 | def _handle_pagination_response(self, response: dict) -> None: 65 | pass 66 | 67 | def _pagination_stream_done(self) -> None: 68 | pass 69 | 70 | def videos(self, **kwargs) -> Generator[Tuple['helix.User', 'helix.Videos'], None, None]: 71 | for user in self: 72 | yield user, user.videos(**kwargs) 73 | 74 | @property 75 | def streams(self) -> Generator[Tuple['helix.User', 'helix.Stream'], None, None]: 76 | for user in self: 77 | yield user, user.stream 78 | -------------------------------------------------------------------------------- /twitch/helix/resources/videos.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Tuple, Generator 2 | 3 | import twitch.helix as helix 4 | import twitch.v5 as v5 5 | from twitch.api import API 6 | from .resource import Resource 7 | 8 | 9 | class VideosAPIException(Exception): 10 | pass 11 | 12 | 13 | class Videos(Resource['helix.Video']): 14 | DEFAULT_FIRST: int = 20 15 | FIRST_API_LIMIT: int = 100 16 | ID_API_LIMIT: int = 100 17 | CACHE_PREFIX: str = 'helix.video.' 18 | 19 | def __init__(self, 20 | api: API, 21 | video_ids: Union[int, List[int]] = None, 22 | **kwargs): 23 | super().__init__(api=api, path='videos') 24 | 25 | # Store kwargs as class property for __iter__ 26 | self._kwargs = kwargs 27 | 28 | # 'id' parameter can be a singular or a list 29 | # Create list of video ids by combining video_ids and kwargs['id'] 30 | 31 | # Convert singular string to list 32 | if 'id' in self._kwargs and type(self._kwargs['id']) == str: 33 | self._kwargs['id'] = [self._kwargs['id']] 34 | 35 | self._kwargs['id'] = list(self._kwargs['id']) if 'id' in self._kwargs.keys() else [] 36 | self._kwargs['id'] = self._kwargs['id'] + list(video_ids) if video_ids else self._kwargs['id'] 37 | 38 | # Convert to integers 39 | self._kwargs['id'] = [int(x) for x in self._kwargs['id']] 40 | 41 | # Remove duplicates 42 | self._kwargs['id'] = list(set(self._kwargs['id'])) if self._kwargs['id'] else [] 43 | 44 | # Download video ids 45 | if len(self._kwargs['id']) > 0: 46 | self._download_video_ids() 47 | 48 | def _can_paginate(self) -> bool: 49 | """ 50 | Kwargs must include user_id or game_id 51 | :return: If resource can paginate 52 | """ 53 | # todo: maybe raise VideosAPIException('A user_id or a game_id must be specified.') 54 | return len([key for key in self._kwargs.keys() if key in ['user_id', 'game_id']]) == 1 55 | 56 | def _cache_videos(self, videos: List['helix.Video']) -> None: 57 | """ 58 | Custom video cache 59 | Cache individual videos 60 | :param videos: Helix videos 61 | :return: None 62 | """ 63 | if self._api.use_cache: 64 | for video in videos: 65 | API.SHARED_CACHE.set(f'{Videos.CACHE_PREFIX}{video.id}', video.data) 66 | 67 | def _handle_pagination_response(self, response: dict) -> List['helix.Video']: 68 | """ 69 | Custom handling for video pagination 70 | :param response: API response data 71 | :return: Videos 72 | """ 73 | videos: List['helix.Video'] = [helix.Video(api=self._api, data=video) for video in response['data']] 74 | self._cache_videos(videos) 75 | 76 | return videos 77 | 78 | def _next_videos_page(self, ignore_cache: bool = False) -> List['helix.Video']: 79 | """ 80 | Video pagination 81 | :param ignore_cache: Ignore API cache 82 | :return: Videos 83 | """ 84 | response: dict = self._next_page(ignore_cache=ignore_cache) 85 | 86 | return self._handle_pagination_response(response) 87 | 88 | def _cache_download(self, video_ids: List[int]) -> List[int]: 89 | """ 90 | Fetch data from cache 91 | :param video_ids: Lookup the video ids 92 | :return: Cache hits (video ids) 93 | """ 94 | cache_hits: list = [] 95 | for video_id in video_ids: 96 | cache_data: dict = API.SHARED_CACHE.get(f'{Videos.CACHE_PREFIX}{video_id}') 97 | if cache_data: 98 | self._data.append(helix.Video(api=self._api, data=cache_data)) 99 | cache_hits.append(video_id) 100 | 101 | return cache_hits 102 | 103 | def _download_video_ids(self) -> None: 104 | """ 105 | Download videos by list of video IDs 106 | :return: 107 | """ 108 | # Custom cache lookup 109 | if self._api.use_cache: 110 | cache_hits: List[int] = self._cache_download(self._kwargs['id']) 111 | 112 | # Removed cached ids from kwargs 113 | self._kwargs['id'] = [n for n in self._kwargs['id'] if n not in cache_hits] 114 | 115 | # Download uncached videos from API 116 | if len(self._kwargs['id']) > 0: 117 | 118 | # When the number of IDs exceeds API limitations, divide into multiple requests 119 | remaining_video_ids: list = self._kwargs['id'] 120 | 121 | while remaining_video_ids: 122 | self._kwargs['id'] = remaining_video_ids[:Videos.ID_API_LIMIT] 123 | 124 | # Ignore default caching method, as we want to cache individual videos and not a collection of videos. 125 | videos: List[helix.Video] = self._next_videos_page(ignore_cache=True) 126 | 127 | # Save videos 128 | self._data.extend(videos) 129 | 130 | # Update remaining video ids 131 | remaining_video_ids = [] if len(videos) < len(remaining_video_ids) else remaining_video_ids[ 132 | Videos.ID_API_LIMIT:] 133 | 134 | @property 135 | def comments(self) -> Generator[Tuple['helix.Video', 'v5.Comments'], None, None]: 136 | for video in self: 137 | yield video, video.comments 138 | -------------------------------------------------------------------------------- /twitch/resource.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic, Generator, List, Optional 2 | 3 | from twitch.api import API 4 | 5 | T = TypeVar('T') 6 | 7 | 8 | class Resource(Generic[T]): 9 | FIRST_API_LIMIT: int = 100 10 | 11 | def __init__(self, path: str, api: API, data: Optional[List[T]] = None): 12 | self._path: str = path 13 | self._api: API = api 14 | self._data: List[T] = data or [] 15 | self._cursor: Optional[str] = None 16 | self._kwargs: dict = {} 17 | 18 | def __iter__(self) -> Generator[T, None, None]: 19 | # Yield available data 20 | if self._data: 21 | for entry in self._data: 22 | yield entry 23 | return 24 | 25 | # Stream data from API 26 | 27 | # Set start cursor 28 | self._cursor = self._kwargs or self._cursor or '0' 29 | 30 | # Set 'first' to limit 31 | self._kwargs['first'] = Resource.FIRST_API_LIMIT 32 | 33 | # Paginate 34 | while self._cursor: 35 | # API Response 36 | response: dict = self._api.get(self._path, params=self._kwargs) 37 | 38 | # Set pagination cursor 39 | self._cursor = response.get('pagination', {}).get('cursor', None) 40 | 41 | def __getitem__(self, item: int) -> T: 42 | return self._data[item] 43 | -------------------------------------------------------------------------------- /twitch/tmi/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from twitch.tmi.models.chatter import Chatter 4 | from twitch.tmi.resources.chatters import Chatters 5 | from twitch.tmi.tmi import TMI 6 | 7 | __all__: List[Callable] = [ 8 | TMI, 9 | Chatter, Chatters 10 | ] 11 | -------------------------------------------------------------------------------- /twitch/tmi/models/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from .chatter import Chatter 4 | from .model import Model 5 | 6 | __all__: List[Callable] = [ 7 | Chatter, 8 | Model, 9 | ] 10 | -------------------------------------------------------------------------------- /twitch/tmi/models/chatter.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .model import Model 6 | 7 | 8 | class Chatter(Model): 9 | 10 | def __init__(self, api: API, name: str, chatter_type: str): 11 | super().__init__(api, {}) 12 | self.name: str = name 13 | self.type: str = chatter_type 14 | 15 | @property 16 | def user(self) -> 'helix.User': 17 | source = helix.Helix('') 18 | source.api = self._api 19 | source.api.base_url = helix.Helix.BASE_URL 20 | return source.user(self.name) 21 | -------------------------------------------------------------------------------- /twitch/tmi/models/model.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from dataclasses import dataclass 3 | from typing import Optional, Dict, Any 4 | 5 | from twitch.api import API 6 | 7 | 8 | @dataclass 9 | class Model(metaclass=ABCMeta): 10 | _api: Optional[API] 11 | data: Optional[Dict[str, Any]] 12 | -------------------------------------------------------------------------------- /twitch/tmi/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from .chatters import Chatters 4 | 5 | __all__: List[Callable] = [ 6 | Chatters 7 | ] 8 | -------------------------------------------------------------------------------- /twitch/tmi/resources/chatters.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, List 2 | 3 | import twitch.tmi as tmi 4 | from twitch.api import API 5 | from twitch.baseresource import BaseResource 6 | 7 | 8 | class Chatters(BaseResource['tmi.Chatter']): 9 | 10 | def __init__(self, api: API, user: str): 11 | super().__init__(api=api, path='group/user/{user}/chatters') 12 | self._api = api 13 | 14 | # API return data 15 | self._data = self._api.get(self._path.format(user=user)) 16 | 17 | # API Data 18 | self.count: int = self._data.get('chatter_count', -1) 19 | 20 | self.types: List[str] = list(self._data.get('chatters', {}).keys()) 21 | 22 | self.broadcaster: List[tmi.Chatter] = [tmi.Chatter(self._api, name, 'broadcaster') for name in 23 | self._data.get('chatters', {}).get('broadcaster', [])] 24 | 25 | self.vips: List[tmi.Chatter] = [tmi.Chatter(self._api, name, 'vip') for name in 26 | self._data.get('chatters', {}).get('vips', [])] 27 | 28 | self.moderators: List[tmi.Chatter] = [tmi.Chatter(self._api, name, 'moderator') for name in 29 | self._data.get('chatters', {}).get('moderators', [])] 30 | 31 | self.staff: List[tmi.Chatter] = [tmi.Chatter(self._api, name, 'staff') for name in 32 | self._data.get('chatters', {}).get('staff', [])] 33 | 34 | self.admins: List[tmi.Chatter] = [tmi.Chatter(self._api, name, 'admin') for name in 35 | self._data.get('chatters', {}).get('admins', [])] 36 | 37 | self.global_mods: List[tmi.Chatter] = [tmi.Chatter(self._api, name, 'global_mod') for name in 38 | self._data.get('chatters', {}).get('global_mods', [])] 39 | 40 | self.viewers: List[tmi.Chatter] = [tmi.Chatter(self._api, name, 'viewer') for name in 41 | self._data.get('chatters', {}).get('viewers', [])] 42 | 43 | def all(self) -> List[tmi.Chatter]: 44 | """ 45 | Get all chatters from all groups 46 | :return: List of all chatters 47 | """ 48 | return self.broadcaster + self.vips + self.moderators + self.staff + self.admins + self.global_mods + self.viewers 49 | 50 | def __iter__(self) -> Generator['tmi.Chatter', None, None]: 51 | """ 52 | Iterate over all chatters 53 | :return: Yield chatter 54 | """ 55 | for chatter in self.all(): 56 | yield chatter 57 | 58 | def __getitem__(self, index: int) -> 'tmi.Chatter': 59 | """ 60 | Get chatter by index 61 | :param index: Index 62 | :return: Chatter 63 | """ 64 | return self.all()[index] 65 | -------------------------------------------------------------------------------- /twitch/tmi/tmi.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Optional 3 | 4 | import twitch.tmi as tmi 5 | from twitch.api import API 6 | 7 | 8 | class TMI: 9 | BASE_URL: str = 'https://tmi.twitch.tv/' 10 | 11 | def __init__(self, 12 | client_id: str, 13 | client_secret: str = None, 14 | use_cache: bool = False, 15 | cache_duration: Optional[timedelta] = None, 16 | handle_rate_limit: bool = True, 17 | bearer_token: Optional[str] = None): 18 | # Format bearer token 19 | if bearer_token: 20 | bearer_token = 'Bearer ' + bearer_token.lower().lstrip('bearer').strip() 21 | 22 | self.api = API(TMI.BASE_URL, 23 | client_id=client_id, 24 | client_secret=client_secret, 25 | use_cache=use_cache, 26 | cache_duration=cache_duration, 27 | handle_rate_limit=handle_rate_limit, 28 | bearer_token=bearer_token) 29 | 30 | def chatters(self, user: str) -> 'tmi.Chatters': 31 | return tmi.Chatters(api=self.api, user=user) 32 | -------------------------------------------------------------------------------- /twitch/v5/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from twitch.v5.models.comment import Comment 4 | from twitch.v5.resources.comments import Comments 5 | from twitch.v5.v5 import V5 6 | 7 | __all__: List[Callable] = [ 8 | V5, 9 | Comment, Comments 10 | ] 11 | -------------------------------------------------------------------------------- /twitch/v5/models/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from .comment import Comment 4 | from .model import Model 5 | 6 | __all__: List[Callable] = [ 7 | Comment, 8 | Model, 9 | ] 10 | -------------------------------------------------------------------------------- /twitch/v5/models/comment.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Dict, Any 2 | 3 | import twitch.helix as helix 4 | from twitch.api import API 5 | from .model import Model 6 | 7 | 8 | class Commenter: 9 | 10 | def __init__(self, data: Dict[str, Any]): 11 | self.data: Dict[str, Any] = data 12 | 13 | self.display_name: str = data.get('display_name') 14 | self.id: str = data.get('_id') 15 | self.name: str = data.get('name') 16 | self.type: str = data.get('type') 17 | self.bio: str = data.get('bio') 18 | self.created_at: str = data.get('created_at') 19 | self.updated_at: str = data.get('updated_at') 20 | self.logo: str = data.get('logo') 21 | 22 | 23 | class Emoticon: 24 | 25 | def __init__(self, data: Dict[str, Any]): 26 | self.data: Dict[str, Any] = data 27 | 28 | self.id: str = data.get('_id') 29 | self.begin: int = data.get('begin') 30 | self.end: int = data.get('end') 31 | self.emoticon_id: str = data.get('emoticon_id') 32 | self.emoticon_set_id: str = data.get('emoticon_set_id') 33 | 34 | 35 | class Fragment: 36 | 37 | def __init__(self, data: Optional[Dict[str, Any]] = None): 38 | self.data: Dict[str, Any] = data 39 | 40 | self.text: str = data.get('text') 41 | self.emoticon: Optional[Emoticon] = Emoticon(data.get('emoticon')) if data.get('emoticon') else None 42 | 43 | 44 | class UserBadge: 45 | 46 | def __init__(self, data: Dict[str, Any]): 47 | self.data: Dict[str, Any] = data 48 | 49 | self.id: str = data.get('_id') 50 | self.version: str = data.get('version') 51 | 52 | 53 | class Message: 54 | 55 | def __init__(self, data: Dict[str, Any]): 56 | self.data: Dict[str, Any] = data 57 | 58 | self.body: str = data.get('body') 59 | self.emoticons: List[Emoticon] = [Emoticon(data) for data in data.get('emoticons', [])] 60 | self.fragments: List[Fragment] = [Fragment(data) for data in data.get('fragments', [])] 61 | self.is_action: bool = data.get('is_action') 62 | self.user_badges: List[UserBadge] = [UserBadge(data) for data in data.get('user_badges', [])] 63 | self.user_color: str = data.get('user_color') 64 | 65 | 66 | class Comment(Model): 67 | 68 | def __init__(self, api: API, data: Dict[str, Any]): 69 | super().__init__(api, data) 70 | 71 | self.id: str = data.get('_id') 72 | self.created_at: str = data.get('created_at') 73 | self.updated_at: str = data.get('updated_at') 74 | self.channel_id: str = data.get('channel_id') 75 | self.content_type: str = data.get('content_type') 76 | self.content_id: str = data.get('content_id') 77 | self.content_offset_seconds: float = data.get('content_offset_seconds') 78 | self.commenter: Commenter = Commenter(data.get('commenter')) if data.get('commenter') else None 79 | self.source: str = data.get('source') 80 | self.state: str = data.get('state') 81 | self.message: Message = Message(data.get('message')) if data.get('message') else None 82 | self.more_replies: bool = data.get('more_replies') 83 | 84 | @property 85 | def user(self) -> 'helix.User': 86 | return helix.Helix(client_id=self._api.client_id).user(int(self.commenter.id)) 87 | -------------------------------------------------------------------------------- /twitch/v5/models/model.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from dataclasses import dataclass 3 | from typing import Optional, Dict, Any 4 | 5 | from twitch.api import API 6 | 7 | 8 | @dataclass 9 | class Model(metaclass=ABCMeta): 10 | _api: Optional[API] 11 | data: Dict[str, Any] 12 | -------------------------------------------------------------------------------- /twitch/v5/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable 2 | 3 | from .comments import Comments 4 | 5 | __all__: List[Callable] = [ 6 | Comments 7 | ] 8 | -------------------------------------------------------------------------------- /twitch/v5/resources/comments.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Generator 2 | 3 | import twitch.v5 as v5 4 | from twitch.api import API 5 | from twitch.baseresource import BaseResource 6 | 7 | 8 | class Comments(BaseResource['v5.Comment']): 9 | 10 | def __init__(self, api: API, video_id: Union[str, int]): 11 | super().__init__(api=api, path='videos/{video_id}/comments') 12 | self._video_id: str = str(video_id) 13 | self._api = api 14 | 15 | def fragment(self, cursor: str = '') -> dict: 16 | return self._api.get(self._path.format(video_id=self._video_id), params={'cursor': cursor}) 17 | 18 | def __iter__(self) -> Generator['v5.Comment', None, None]: 19 | fragment: dict = {'_next': ''} 20 | 21 | while '_next' in fragment: 22 | fragment = self.fragment(fragment['_next']) 23 | for comment in fragment['comments']: 24 | yield v5.Comment(api=self._api, data=comment) 25 | 26 | def __getitem__(self, item: int) -> 'v5.Comment': 27 | for index, value in enumerate(self): 28 | if index == item: 29 | return value 30 | -------------------------------------------------------------------------------- /twitch/v5/v5.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import List, Union 3 | 4 | import twitch.v5 as v5 5 | from twitch.api import API 6 | 7 | 8 | class V5: 9 | BASE_URL: str = 'https://api.twitch.tv/v5/' 10 | COMMENTS_CLIENT_ID: str = 'kimne78kx3ncx6brgo4mv6wki5h1ko' 11 | 12 | def __init__(self, client_id: str, 13 | client_secret: str = None, 14 | request_rate: int = None, 15 | use_cache: bool = False, 16 | cache_duration: timedelta = timedelta(minutes=30)): 17 | self.client_secret: str = client_secret 18 | self.scope: List[str] = [] 19 | 20 | self.api = API(V5.BASE_URL, 21 | client_id, 22 | use_cache=use_cache, 23 | cache_duration=cache_duration, 24 | request_rate=request_rate) 25 | 26 | @staticmethod 27 | def comments(video_id: Union[str, int]) -> 'v5.Comments': 28 | return v5.Comments(api=API(V5.BASE_URL, V5.COMMENTS_CLIENT_ID), video_id=video_id) 29 | --------------------------------------------------------------------------------