From 71079428d9f868ecd164c14ab92608405dce450e Mon Sep 17 00:00:00 2001 From: Vitalii Lebedynskyi Date: Mon, 8 Aug 2022 12:29:11 +0300 Subject: [PATCH] Initial commit. Added downloader --- .gitignore | 162 +++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ configs/config.py | 0 configs/reader.py | 0 main.py | 85 +++++++++++++++++++++++ requirements.txt | 2 + stream/__init__.py | 0 stream/downloader.py | 134 +++++++++++++++++++++++++++++++++++ 8 files changed, 387 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 configs/config.py create mode 100644 configs/reader.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 stream/__init__.py create mode 100644 stream/downloader.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..270d79e --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +recorded \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c1258c --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +## Known issues: +- Configure logger with config file +- Support multiple streamer +- Post process with ffmpeg \ No newline at end of file diff --git a/configs/config.py b/configs/config.py new file mode 100644 index 0000000..e69de29 diff --git a/configs/reader.py b/configs/reader.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..ea15fa6 --- /dev/null +++ b/main.py @@ -0,0 +1,85 @@ +import argparse +import os +import sys +import logging + +from stream.downloader import TwitchRecorder + + +def parse_arguments(): + parser = argparse.ArgumentParser(description='Twitch highlighter') + parser.add_argument('--client', "-c", help='Twitch client id', required=True, dest="tw_client") + parser.add_argument('--secret', "-s", help='Twitch secret id', required=True, dest="tw_secret") + parser.add_argument('--user', "-u", help='Twitch streamer username', required=True, dest="tw_streamer") + parser.add_argument('--quality', "-q", help='Video downloading quality', dest="tw_quality", default="360p") + parser.add_argument('--output_path', "-o", help='Video download folder', dest="output_path", default=os.getcwd()) + + return parser.parse_args() + + +def on_downloaded(): + pass + + +if __name__ == "__main__": + # TODO configure logging + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + + args = parse_arguments() + + rec = TwitchRecorder(args.tw_client, args.tw_secret, args.tw_streamer, args.output_path, + args.tw_quality, on_download=on_downloaded) + + rec.run() + +# Twitch downloader +# def main(argv): +# twitch_recorder = TwitchRecorder() +# usage_message = "twitch-recorder.py -u -q " +# logging.basicConfig(filename="twitch-recorder.log", level=logging.INFO) +# logging.getLogger().addHandler(logging.StreamHandler()) +# +# try: +# opts, args = getopt.getopt(argv, "hu:q:l:", +# ["username=", "quality=", "log=", "logging=", "disable-ffmpeg", 'uid=']) +# except getopt.GetoptError: +# print(usage_message) +# sys.exit(2) +# print(opts) +# for opt, arg in opts: +# if opt == "-h": +# print(usage_message) +# sys.exit() +# elif opt in ("-u", "--username"): +# twitch_recorder.username = arg +# elif opt in ("-q", "--quality"): +# twitch_recorder.quality = arg +# elif opt in ("-l", "--log", "--logging"): +# logging_level = getattr(logging, arg.upper(), None) +# if not isinstance(logging_level, int): +# raise ValueError("invalid log level: %s" % logging_level) +# logging.basicConfig(level=logging_level) +# logging.info("logging configured to %s", arg.upper()) +# elif opt in "--uid": +# twitch_recorder.stream_uid = arg +# elif opt == "--disable-ffmpeg": +# twitch_recorder.disable_ffmpeg = True +# logging.info("ffmpeg disabled") +# +# twitch_recorder.run() +# +# +# if __name__ == "__main__": +# main(sys.argv[1:]) + +# # fix videos from previous recording session +# try: +# video_list = [f for f in os.listdir(recorded_path) if os.path.isfile(os.path.join(recorded_path, f))] +# if len(video_list) > 0: +# logging.info("processing previously recorded files") +# for f in video_list: +# recorded_filename = os.path.join(recorded_path, f) +# processed_filename = os.path.join(processed_path, f) +# self.process_recorded_file(recorded_filename, processed_filename) +# except Exception as e: +# logging.error(e) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7c40d65 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests==2.28.1 +streamlink==4.2.0 \ No newline at end of file diff --git a/stream/__init__.py b/stream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stream/downloader.py b/stream/downloader.py new file mode 100644 index 0000000..f902a4e --- /dev/null +++ b/stream/downloader.py @@ -0,0 +1,134 @@ +import datetime +import enum +import logging +import os +import subprocess +import time +import requests + +TOKEN_URL = "https://id.twitch.tv/oauth2/token?client_id={0}&client_secret={1}&grant_type=client_credentials" +HELIX_STREAM_URL = "https://api.twitch.tv/helix/streams?user_login={0}" + +logger = logging.getLogger(__name__) + + +class TwitchStreamerStatus(enum.Enum): + ONLINE = 0 + OFFLINE = 1 + NOT_FOUND = 2 + UNAUTHORIZED = 3 + ERROR = 4 + + +class TwitchRecorder: + access_token = None + + def __init__(self, client_id, client_secret, username, output_path, quality="480p", on_download=None): + # global configuration + self.disable_ffmpeg = False + self.refresh_timeout = 15 + self.output_path = output_path + self.stream_uid = None + self.on_download = on_download + + # twitch configuration + self.username = username + self.quality = quality + self.client_id = client_id + self.client_secret = client_secret + self.token_url = TOKEN_URL.format(self.client_id, self.client_secret) + + def fetch_access_token(self): + token_response = requests.post(self.token_url, timeout=15) + token_response.raise_for_status() + token = token_response.json() + return token["access_token"] + + def run(self): + # path to recorded stream + recording_path = os.path.join(self.output_path, "recorded", self.username) + + # create directory for recordedPath and processedPath if not exist + if os.path.isdir(recording_path) is False: + os.makedirs(recording_path) + + # make sure the interval to check user availability is not less than 15 seconds + if self.refresh_timeout < 15: + logger.warning("check interval should not be lower than 15 seconds") + self.refresh_timeout = 15 + logger.warning("check interval set to 15 seconds") + + self.access_token = self.fetch_access_token() + self.loop_check(recording_path) + + def loop_check(self, recording_path): + logger.info("checking for %s every %s seconds, recording with %s quality", + self.username, self.refresh_timeout, self.quality) + while True: + status, info = self.check_user() + if status == TwitchStreamerStatus.NOT_FOUND: + logger.error("username not found, invalid username or typo") + time.sleep(self.refresh_timeout) + elif status == TwitchStreamerStatus.ERROR: + logger.error("%s unexpected error. will try again in 5 minutes", + datetime.datetime.now().strftime("%Hh%Mm%Ss")) + time.sleep(300) + elif status == TwitchStreamerStatus.OFFLINE: + logger.info("%s currently offline, checking again in %s seconds", self.username, self.refresh_timeout) + time.sleep(self.refresh_timeout) + elif status == TwitchStreamerStatus.UNAUTHORIZED: + logger.info("unauthorized, will attempt to log back in immediately") + self.access_token = self.fetch_access_token() + elif status == TwitchStreamerStatus.ONLINE: + logger.info("%s online, stream recording in session", self.username) + + channels = info["data"] + channel = next(iter(channels), None) + + recorded_filename = self.download_stream(channel, recording_path) + + logger.info("recording stream is done") + + if self.on_download is not None: + self.on_download(recorded_filename) + + time.sleep(self.refresh_timeout) + + def check_user(self): + info = None + status = TwitchStreamerStatus.ERROR + try: + headers = {"Client-ID": self.client_id, "Authorization": "Bearer " + self.access_token} + r = requests.get(HELIX_STREAM_URL.format(self.username), headers=headers, timeout=15) + r.raise_for_status() + info = r.json() + if info is None or not info["data"]: + status = TwitchStreamerStatus.OFFLINE + else: + status = TwitchStreamerStatus.ONLINE + except requests.exceptions.RequestException as e: + if e.response: + if e.response.status_code == 401: + status = TwitchStreamerStatus.UNAUTHORIZED + if e.response.status_code == 404: + status = TwitchStreamerStatus.NOT_FOUND + return status, info + + def download_stream(self, channel, recording_path): + filename = self.username + " - " + datetime.datetime.now() \ + .strftime("%Y-%m-%d %Hh%Mm%Ss") + " - " + channel.get("title") + ".mp4" + + filename = "".join(x for x in filename if x.isalnum() or x in [" ", "-", "_", "."]) + recorded_filename = os.path.join(recording_path, filename) + + # start streamlink process + subprocess.call([ + "streamlink", + "--twitch-disable-ads", + "twitch.tv/" + self.username, + self.quality, + "-o", + recorded_filename + ]) + + return recorded_filename