Initial commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
BIN
app/__pycache__/discord_client.cpython-311.pyc
Normal file
BIN
app/__pycache__/discord_client.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/twitch_client.cpython-311.pyc
Normal file
BIN
app/__pycache__/twitch_client.cpython-311.pyc
Normal file
Binary file not shown.
123
app/discord_client.py
Normal file
123
app/discord_client.py
Normal 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
79
app/main.py
Normal 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
180
app/twitch_client.py
Normal 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")
|
||||
Reference in New Issue
Block a user