Initial commit

This commit is contained in:
Deko
2023-03-03 20:38:17 +01:00
commit 61435c69e7
18 changed files with 707 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
STREAMER_NAME="example"
DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."
TWITCH_CLIENT_ID=""
TWITCH_CLIENT_SECRET=""

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

8
.idea/MelouGoesLive.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Poetry (MelouGoesLive)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/MelouGoesLive.iml" filepath="$PROJECT_DIR$/.idea/MelouGoesLive.iml" />
</modules>
</component>
</project>

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.11.2
COPY ./ /
WORKDIR /
RUN apt-get update
RUN apt-get -y dist-upgrade
RUN apt-get -y install python3-pip curl
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH=/root/.local/bin:$PATH
RUN poetry install
CMD ["poetry", "run", "python", "app/main.py"]

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# Discord Twitch Live Notification
This is a python project to send a Discord webhook with a self-updating webhook
when a specified streamer goes live on Twitch.
Checks and updates exactly once every half minute.
The motivation behind this project is that requiring discord.js or the twitch api library is too much in my opinion.
Doing this requires 7 API calls, including the really basic authentication in twitch's case.
All options to run this require environment variables. You can see them in [this file](.env).
## Running locally
The first option to run the project is to run it locally.
You may install the dependencies through pip, however it is recommended to install them with the project default, [poetry](https://python-poetry.org).
### Install prerequisites
- Python 3.11.2
Clone the repository:
```commandline
git clone https://github.com/Gadsee/Discord-Twitch-Live-Notifications.git
```
Install poetry. (Taken from the [official documentation](https://python-poetry.org/docs/).)
Debian-based linux distributions:
```bash
sudo apt-get install python3-pip curl
curl -sSL https://install.python-poetry.org | python3 -
```
Windows:
```powershell
(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py -
```
### Install dependencies
```commandline
poetry env use 3.11.2
poetry install
```
### Run the app
Replace `source .env` with your OS' appropriate way of loading environment variables.
```bash
poetry shell
source .env
python app/main.py
```
## Running in Docker
The second option is to run the project's docker image.

0
app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

123
app/discord_client.py Normal file
View File

@@ -0,0 +1,123 @@
import os
import requests
from loguru import logger
from twitch_client import StreamInformation
class DiscordClient:
_notification_msg_id: str = ""
def __init__(self):
self._webhook_url = os.environ["DISCORD_WEBHOOK_URL"]
def send_information_to_discord(
self, stream: StreamInformation, profile_image: str
) -> str:
logger.info("Sending a message with an embed to the webhook...")
streamer_url = f"https://www.twitch.tv/{stream.user_login}/"
response = requests.post(
url=f"{self._webhook_url}?wait=true",
json={
"username": "Oak Tree",
"avatar_url": "https://i.imgur.com/DBOuwjx.png",
"content": "@everyone",
"embeds": [
{
"title": stream.title,
"color": 8388863,
"timestamp": stream.started_at,
"url": streamer_url,
"author": {
"name": stream.user_name,
"url": streamer_url,
"icon_url": profile_image,
},
"image": {"url": stream.thumbnail_url},
"fields": [
{
"name": "Game",
"value": stream.game_name,
"inline": True,
},
{
"name": "Viewers",
"value": stream.viewer_count,
"inline": True,
},
],
}
],
},
)
response.raise_for_status()
self._notification_msg_id = response.json()["id"]
logger.info("Stream information sent with ping to Discord.")
return self._notification_msg_id
def update_information_on_discord(
self, stream: StreamInformation, profile_image: str
) -> None:
logger.info("Updating stream information on Discord...")
streamer_url = f"https://www.twitch.tv/{stream.user_login}/"
response = requests.patch(
url=f"{self._webhook_url}/messages/{self._notification_msg_id}",
json={
"embeds": [
{
"title": stream.title,
"color": 8388863,
"timestamp": stream.started_at,
"url": streamer_url,
"author": {
"name": f"{stream.user_name}",
"url": streamer_url,
"icon_url": profile_image,
},
"image": {"url": stream.thumbnail_url},
"fields": [
{
"name": "Game",
"value": stream.game_name,
"inline": True,
},
{
"name": "Viewers",
"value": stream.viewer_count,
"inline": True,
},
],
}
],
},
)
response.raise_for_status()
logger.info("Message embed content updated.")
def finalize_information_on_discord(
self, streamer_name, vod_url: str | None
) -> None:
logger.info("Finalizing stream information on Discord...")
if not self._notification_msg_id:
logger.info("Message ID not set, nothing to finalize.")
return
if not vod_url:
vod_url = "None available. Please contact the developer."
response = requests.patch(
url=f"{self._webhook_url}/messages/{self._notification_msg_id}",
json={
"username": "Oak Tree",
"avatar_url": "https://i.imgur.com/DBOuwjx.png",
"content": (
f"{streamer_name} stopped the stream. VOD: \n{vod_url}"
),
"embeds": [],
},
)
response.raise_for_status()
logger.info("Message updated with VOD.")

79
app/main.py Normal file
View File

@@ -0,0 +1,79 @@
import os
import time
from loguru import logger
from requests import HTTPError
from discord_client import DiscordClient
from twitch_client import TwitchClient, StreamInformation
class Main:
is_live: bool = False
_previous_stream: StreamInformation = None
def __init__(self):
self.twitch_client = TwitchClient(streamer=os.environ["STREAMER_NAME"])
self.twitch_client.update_access_token()
self.profile_image = self.twitch_client.get_streamer_profile_picture()
self.discord_client = DiscordClient()
def update_status(self):
try:
stream = self.twitch_client.get_stream()
if not stream:
if self.is_live:
logger.info("Streamer went offline.")
self.is_live = False
self.discord_client.finalize_information_on_discord(
streamer_name=self._previous_stream.user_name,
vod_url=self.twitch_client.get_vod(
user_id=self._previous_stream.user_id
),
)
return
if not self.is_live:
logger.info("Streamer went live.")
self.discord_client.send_information_to_discord(
stream=stream, profile_image=self.profile_image
)
self.is_live = True
else:
logger.info("Streamer still live.")
self.discord_client.update_information_on_discord(
stream=stream, profile_image=self.profile_image
)
self._previous_stream = stream
except HTTPError as e:
logger.exception(e)
def interrupt(self):
if not self.is_live:
return
self.is_live = False
self.discord_client.finalize_information_on_discord(
streamer_name=self._previous_stream.user_login,
vod_url=self.twitch_client.get_vod(
user_id=self._previous_stream.user_id
),
)
if __name__ == "__main__":
logger.info("Initiating main...")
start_time = time.time()
main = Main()
logger.info("Set-up looks correct, starting main loop.")
try:
while True:
main.update_status()
time.sleep(30.0 - ((time.time() - start_time) % 30.0))
except (SystemExit, KeyboardInterrupt):
logger.info("Caught wish to exit, interrupting and re-raising.")
main.interrupt()
raise

180
app/twitch_client.py Normal file
View File

@@ -0,0 +1,180 @@
import os
import random
from dataclasses import dataclass
import requests
from loguru import logger
from requests import HTTPError
@dataclass
class CachePrevent:
calls: int = 0
random_number: int = 0
five_minute_update_modulo: int = 10
def prevent_cache_on_url(self, url: str) -> str:
self.calls += 1
if self.calls % self.five_minute_update_modulo == 0:
self.random_number = random.randint(0, 999999)
return f"{url}?{self.random_number}"
@dataclass
class StreamInformation:
user_id: str
user_name: str
user_login: str
title: str
game_name: str
viewer_count: int
started_at: str
_thumbnail_url: str
@property
def thumbnail_url(self):
return (
self._thumbnail_url
.replace("{width}", "1280")
.replace("{height}", "720")
)
class TwitchClient:
_access_token: str = ""
def __init__(self, streamer: str):
self.streamer = streamer
self._client_id = os.environ["TWITCH_CLIENT_ID"]
self._client_secret = os.environ["TWITCH_CLIENT_SECRET"]
self._cache_prevent = CachePrevent()
def update_access_token(self) -> None:
logger.info("Updating twitch access token...")
response = requests.post(
url="https://id.twitch.tv/oauth2/token",
headers={"Content-Type": "application/x-www-form-url-encoded"},
params={
"client_id": self._client_id,
"client_secret": self._client_secret,
"grant_type": "client_credentials",
},
)
response.raise_for_status()
self._access_token = response.json()["access_token"]
logger.info("Updating twitch access token done.")
def _update_access_token_wrapper(self) -> bool:
try:
self.update_access_token()
except HTTPError as e:
logger.error("API call to update twitch access token failed.")
logger.exception(e)
return False
except KeyError:
logger.error("Access token was not in auth response.")
return False
return True
def get_streamer_profile_picture(
self, is_retry: bool = False
) -> str | None:
response = requests.get(
url="https://api.twitch.tv/helix/users",
headers={
"Client-Id": self._client_id,
"Authorization": f"Bearer {self._access_token}",
},
params={"login": self.streamer},
)
if response.status_code == 401:
logger.info("Getting streamer returned an auth issue.")
if is_retry:
logger.error("Auth failed twice, aborting.")
return None
if not self._update_access_token_wrapper():
return None
return self.get_streamer_profile_picture(is_retry=True)
response.raise_for_status()
return response.json()["data"][0]["profile_image_url"]
def get_stream(self, is_retry: bool = False) -> StreamInformation | None:
response = requests.get(
url="https://api.twitch.tv/helix/streams",
headers={
"Client-Id": self._client_id,
"Authorization": f"Bearer {self._access_token}",
},
params={"user_login": self.streamer},
)
if response.status_code == 401:
logger.info("Getting streams returned an auth issue.")
if is_retry:
logger.error("Auth failed twice, aborting.")
return None
if not self._update_access_token_wrapper():
return None
return self.get_stream(is_retry=True)
response.raise_for_status()
streams = response.json()["data"]
if len(streams) == 0:
return None
stream_data = streams[0]
return StreamInformation(
user_id=stream_data.get("user_id"),
user_name=stream_data.get("user_name"),
user_login=stream_data.get("user_login"),
title=stream_data.get("title"),
game_name=stream_data.get("game_name"),
viewer_count=stream_data.get("viewer_count"),
started_at=stream_data.get("started_at"),
_thumbnail_url=self._cache_prevent.prevent_cache_on_url(
url=stream_data.get("thumbnail_url"),
)
)
def get_vod(self, user_id: str, is_retry: bool = False) -> str | None:
response = requests.get(
url="https://api.twitch.tv/helix/videos",
headers={
"Client-Id": self._client_id,
"Authorization": f"Bearer {self._access_token}",
},
params={"user_id": user_id},
)
if response.status_code == 401:
logger.info("Getting vod returned an auth issue.")
if is_retry:
logger.error("Auth failed twice, aborting.")
return None
if not self._update_access_token_wrapper():
return None
return self.get_vod(user_id=user_id, is_retry=True)
response.raise_for_status()
vods = response.json()["data"]
if len(vods) == 0:
return None
vod_data = vods[0]
return vod_data.get("url")

195
poetry.lock generated Normal file
View File

@@ -0,0 +1,195 @@
[[package]]
name = "black"
version = "23.1.0"
description = "The uncompromising code formatter."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "certifi"
version = "2022.12.7"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "charset-normalizer"
version = "3.0.1"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
[[package]]
name = "idna"
version = "3.4"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "isort"
version = "5.12.0"
description = "A Python utility / library to sort Python imports."
category = "main"
optional = false
python-versions = ">=3.8.0"
[package.extras]
colors = ["colorama (>=0.4.3)"]
requirements-deprecated-finder = ["pip-api", "pipreqs"]
pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"]
plugins = ["setuptools"]
[[package]]
name = "loguru"
version = "0.6.0"
description = "Python logging made (stupidly) simple"
category = "main"
optional = false
python-versions = ">=3.5"
[package.dependencies]
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "black (>=19.10b0)", "isort (>=5.1.1)", "Sphinx (>=4.1.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)"]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "packaging"
version = "23.0"
description = "Core utilities for Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "pathspec"
version = "0.11.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "platformdirs"
version = "3.0.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)", "sphinx (>=6.1.3)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2.1)"]
[[package]]
name = "requests"
version = "2.28.2"
description = "Python HTTP for Humans."
category = "main"
optional = false
python-versions = ">=3.7, <4"
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<1.27"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "urllib3"
version = "1.26.14"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.extras]
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "win32-setctime"
version = "1.1.0"
description = "A small Python utility to set file creation time on Windows"
category = "main"
optional = false
python-versions = ">=3.5"
[package.extras]
dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.11"
content-hash = "2cd384690a48bd802789a049147162c975c512098cc42b7716ba54b06f448092"
[metadata.files]
black = []
certifi = []
charset-normalizer = []
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
colorama = []
idna = []
isort = []
loguru = []
mypy-extensions = []
packaging = []
pathspec = []
platformdirs = []
requests = []
urllib3 = []
win32-setctime = []

21
pyproject.toml Normal file
View File

@@ -0,0 +1,21 @@
[tool.poetry]
name = "melougoeslive"
version = "0.1.0"
description = ""
authors = ["Deko <gadse.deko@gmail.com>"]
[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.28.2"
loguru = "^0.6.0"
black = "^23.1.0"
isort = "^5.12.0"
[tool.poetry.dev-dependencies]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 80