diff options
| author | Pinapelz <yukais@pinapelz.com> | 2024-01-28 21:13:08 -0800 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2024-01-28 21:13:08 -0800 |
| commit | 6255f111989d75860b2b0cc136fc8d1444ffb57b (patch) | |
| tree | 1e95199d23421e80752d64ee78d42416e955513f | |
Initial commit
| -rw-r--r-- | .gitignore | 160 | ||||
| -rw-r--r-- | currencies.py | 26 | ||||
| -rw-r--r-- | yt-livechat-stats.py | 108 |
3 files changed, 294 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# 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/ diff --git a/currencies.py b/currencies.py new file mode 100644 index 0000000..d95df95 --- /dev/null +++ b/currencies.py @@ -0,0 +1,26 @@ +import requests +import json + +class CurrencyConv: + def __init__(self, currency_json_url: str="https://open.er-api.com/v6/latest/USD"): + try: + self._currency_data = json.loads(requests.get(currency_json_url).text) + except: + raise Exception("Unable to load currency data from " + currency_json_url) + self._currency_rates = self._currency_data["rates"] + + def convert(self, amount: float, from_currency: str, to_currency: str) -> float: + # handle some conversion errors + from_currency = from_currency.replace("\xa0", "").strip().upper() + from_currency= from_currency.replace("₱", "PHP") + from_currency = from_currency.replace("¥", "JPY") + try: + if from_currency == to_currency: + return amount + return amount * self._currency_rates[to_currency] / self._currency_rates[from_currency] + except: + raise Exception("Unable to convert from " + from_currency + " to " + to_currency) + +if __name__ == "__main__": + converter = CurrencyConv() + print(converter.convert(100.0, "SGD", "USD")) diff --git a/yt-livechat-stats.py b/yt-livechat-stats.py new file mode 100644 index 0000000..1639121 --- /dev/null +++ b/yt-livechat-stats.py @@ -0,0 +1,108 @@ +import pytchat +import time +import argparse +from currencies import CurrencyConv +import curses +import os + + +class StreamEndedError(Exception): + pass + +def main(stdscr, video_id: str, args: argparse.Namespace): + curses.curs_set(0) + height, width = stdscr.getmaxyx() + max_num_messages = -(height-4) if args.max_messages is None else args.max_messages + stdscr.nodelay(True) + chat_window = curses.newwin(height - 4, width, 8, 0) + + chat = pytchat.create(video_id=video_id) + + message_count = 0 + current_earnings_superchat_usd = 0.0 + membership_count = 0 + superchat_messages = [] + start_time = time.time() + start_time_const = time.time() + if args.earnings: + currency_converter = CurrencyConv() + + try: + while chat.is_alive(): + for chat_data in chat.get().sync_items(): + key = stdscr.getch() + if key == ord('q'): + raise Exception("User ended data collection") + if args.earnings and chat_data.amountValue != 0.0: + current_earnings_superchat_usd += currency_converter.convert(chat_data.amountValue, chat_data.currency, "USD") + superchat_messages.append((chat_data.author.name, chat_data.amountString, chat_data.message)) + + if args.earnings and "Welcome New Member!" in chat_data.message: + superchat_messages.append((chat_data.author.name, "Membership", chat_data.message)) + membership_count += 1 + + message_count += 1 + + elapsed_time = time.time() - start_time + if elapsed_time >= 1: + stdscr.erase() + messages_per_second = message_count / elapsed_time + if args.track_moments > 0 and messages_per_second > args.track_moments: + stdscr.addstr(1, 0, "Message rate exceeded " + str(args.track_moments) + " at " + str(time.time()), curses.A_BLINK) + if not os.path.exists(f"{video_id}-moments.txt"): + open(f"{video_id}-moments.txt", "w").close() + with open(f"{video_id}-moments.txt", "a") as f: + timestamp = chat_data.timestamp + if args.start_time > 0: + timestamp = timestamp - args.start_time + timestamp = time.strftime("%H:%M:%S", time.gmtime(timestamp)) + + f.write(f"Message rate exceeded {args.track_moments} per second at {chat_data.timestamp}\n") + messages_per_second_text = f"Messages per second: {messages_per_second:.2f}" + timestamp_since_start = time.strftime("%H:%M:%S", time.gmtime(time.time() - start_time_const)) + info_msg = "Now collecting data for " + video_id + " Elapsed time: " + timestamp_since_start + " seconds (Press q to quit)" + chat_window_seperator_txt = "-" * width + stdscr.addstr(0, 0, info_msg, curses.A_BOLD) + stdscr.addstr(2, 0, messages_per_second_text) + stdscr.addstr(6, 0, chat_window_seperator_txt) + + if args.earnings: + current_earnings_usd = current_earnings_superchat_usd + membership_count * args.membership + earnings_text = f"Current earnings (USD): {current_earnings_usd:.2f}" + membership_count_text = f"New/Renewing Members: {membership_count}" + stdscr.addstr(3, 0, earnings_text) + stdscr.addstr(4, 0, membership_count_text) + + stdscr.refresh() + message_count = 0 + start_time = time.time() + + # Update chat window + chat_window.erase() + for i, message in enumerate(superchat_messages[max_num_messages:]): + chat_window.addstr(i, 0, f"{message[0]}: {message[1]} - {message[2]}") + chat_window.refresh() + + except Exception as e: + with open(f"{video_id}.txt", "w") as f: + f.write(f"Data collection ended due to: {str(e)}\n") + f.write(f"Messages per second: {messages_per_second:.2f}\n") + if args.earnings: + f.write(f"Current earnings (USD): {current_earnings_usd:.2f}\n") + f.write(f"New/Renewing Members: {membership_count}\n") + f.write("--- Logged Superchats ---\n") + for message in superchat_messages: + f.write(f"{message[0]}: {message[1]} - {message[2]}\n") + print("Saving data collected so far to " + f"{video_id}.txt") + print(e) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='YouTube Live Chat Message Rate Tracker') + parser.add_argument('video_id', help='The ID of the YouTube video for the live chat') + parser.add_argument('--earnings', action='store_true', help='Show earnings through Superchats, Super Stickers, and Memberships') + parser.add_argument('--membership', type=float, default=4.99, help='The amount of money a membership is worth') + parser.add_argument('--track-moments', type=int, default=0, help='Log timestamps when the message rate exceeds this value') + parser.add_argument('--max-messages', type=int, default=None, help="The maximum number of messages to collect") + parser.add_argument('-start-time', type=int, default=0, help='The known UNIX timestamp of when the stream started (for calculating timestamps') + args = parser.parse_args() + curses.wrapper(main, args.video_id, args) |
