├── .github └── FUNDING.yml ├── .gitignore ├── Dockerfile ├── README.md ├── assets ├── icon.icns └── icon.ico ├── dev_tools ├── build-linux.sh ├── build-macos.sh ├── build-windows.sh ├── win-launcher.cmd └── win-venv.cmd ├── docker-compose.yml ├── docker-entrypoint.sh ├── hooks └── use_lib.py ├── requirements-dev.txt ├── requirements.txt ├── src ├── app.py ├── constants.py ├── macos.py ├── main.py ├── menu.py └── utils.py └── version.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: ucarno 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | ow-league-tokens.spec 3 | 4 | __pycache__/ 5 | build/ 6 | dist/ 7 | venv/ 8 | 9 | src/config.json 10 | src/profiles/ 11 | src/debug/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.3-slim 2 | 3 | # Install Google Chrome and xvfb 4 | # https://stackoverflow.com/questions/70955307/how-to-install-google-chrome-in-a-docker-container 5 | RUN DEBIAN_FRONTEND=nointeractive && apt update -y && apt install -y wget xvfb gnupg2 && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list && apt update && apt -y install google-chrome-stable 6 | 7 | # Setup app 8 | WORKDIR /app 9 | COPY . . 10 | RUN pip3 install -r requirements.txt 11 | 12 | RUN chmod +x docker-entrypoint.sh 13 | 14 | ENTRYPOINT ["/bin/sh", "-c", "./docker-entrypoint.sh"] 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overwatch League Tokens Bot 2 | 3 | This is an app that watches League streams for you on YouTube! 4 | Now less experimental, but [issues](#known-issues) may still occur. 5 | 6 | Uses actual browser (Google Chrome or Brave) to watch streams. 7 | 8 | **No Contenders skins support** as Contenders skins are earned via Twitch drops - not watching on YouTube. 9 | To earn Contenders skins in the same automated fashion, try [this](https://github.com/DevilXD/TwitchDropsMiner) app. 10 | 11 |
12 | 13 | [![Join Discord](https://i.imgur.com/dUQDNfo.png)](https://discord.gg/kkq2XY4cJM) 14 | 15 |
16 | 17 | ## Features 18 | * Automatic live broadcast detection — don't worry about "when" and "where" 19 | * Multiple accounts support — you just need multiple Google accounts 20 | * Headless mode — see only a console window ([as before](https://github.com/ucarno/ow-league-tokens/tree/legacy)) if extra Chrome window is bothering you 21 | * No sound — Chrome will be muted entirely 22 | * Easy setup on Windows, macOS and Linux (GUI) 23 | 24 | ## Planned Features 25 | * Automatically set broadcast quality to 144p, so it doesn't consume a lot of bandwidth 26 | _(current workaround: set stream quality by yourself, YouTube should remember your choice)_ 27 | * Script for updating 28 | * ~~Mobile phones support~~ 29 | 30 | ## You need a browser for this app to work! 31 | This bot uses [undetected-chromedriver](https://github.com/ultrafunkamsterdam/undetected-chromedriver) 32 | under the hood that requires either Google Chrome or Brave to be installed. 33 | 34 | To use Brave (or if your Google Chrome installation could not be found), 35 | set `chromium_binary` field in `config.json` to your browser's executable path. 36 | 37 | Firefox is not supported 38 | (support can be technically implemented, but Google will be able to detect automation). 39 | 40 | ## Installation 41 | ### Windows, macOS, Linux (GUI) 42 | 1. Download the latest executable for your OS [here](https://github.com/ucarno/ow-league-tokens/releases/latest). 43 | 2. Unpack zip anywhere you want. 44 | 3. Run `ow-league-tokens` to open the app. 45 | 4. Windows: Ignore Windows Defender's complaints about "unknown publisher". 46 | Also, you may need to add this app to your antivirus exceptions. 47 | 48 | ### Linux (no GUI) 49 | **You need to have GUI to log into your Google account(s)!** 50 | There is no good way to run this app without a GUI. 51 | Best option would be to just install GUI on your Linux and [log in using GUI](#windows-macos-linux-gui). 52 | 53 | **You have been warned!** If you want to play with it, then see instructions for [Docker](#docker). 54 | 55 | ## Usage 56 | **Make sure you have connected Battle.net account(s) to Google account(s) 57 | on [this](https://www.youtube.com/account_sharing) page!** 58 | 59 | 1. Start the bot using a first menu option. 60 | You should see Chrome window(s) opening, with text in console guiding you. 61 | 2. When you see Google's login screen - log in to your account. 62 | 3. Then you should be redirected to the YouTube page, and bot will confirm that everything is OK by writing a success 63 | message in console. 64 | 65 | ## Updating 66 | Sometimes you may see "new version available" message in your console. It probably means that I've fixed something. 67 | 68 | Bot can be updated without losing your profile(s) data (no need to login into Google again): 69 | 1. Download the latest version from [here](https://github.com/ucarno/ow-league-tokens/releases/latest). 70 | 2. Either: 71 | * Unpack it anywhere you want and move `config.json` file and `profiles` directory from an old version to new one... 72 | * or move new files to old directory, replacing old files with new ones. 73 | 74 | ## Command-line Arguments 75 | * Use `--nomenu` (or `Start_Without_Menu.bat` on Windows) to run the app without a menu using your `config.json` file. 76 | * Use `--nowait` for a script to close immediately after an error 77 | (without `--nowait` you have to manually press Enter for script to close after an error) 78 | * Use `--profiles` to specify profiles you want this app to use (works only with `--nomenu` argument). 79 | Usage: `--nomenu --profiles my-main friends-acc` 80 | * There is also a specific flag for Docker that may solve some issues under Linux: `--docker` 81 | (includes some Chromium flags, works only with `--nomenu` argument) 82 | 83 | ## Docker 84 | This application supports Docker (sort of, I couldn't make profiles reusable), 85 | track progress on Docker support [here](https://github.com/ucarno/ow-league-tokens/issues/63)! 86 | You can either build it by using the supplied `docker-compose.yml` or `Dockerfile`. 87 | 88 | 1. Clone this repository using `git clone https://github.com/ucarno/ow-league-tokens` 89 | 2. Go to app's directory using `cd ow-league-tokens` 90 | 3. Edit `docker-entrypoint.sh` to include your profile names if needed. 91 | 92 | ### Docker Compose (recommended way if using Docker) 93 | 1. Make sure Docker Compose is installed on your machine! More info [here](https://docs.docker.com/compose/). 94 | 2. `docker compose up -d` - build container using the Dockerfile 95 | * `docker compose ps` to verify if container is running 96 | * `docker compose logs -f` to view container's logs 97 | 98 | ### Dockerfile 99 | 1. `docker build -t ow-league-tokens .` to build container using the Dockerfile. 100 | 2. `docker run -d -v ./src/profiles:/profiles ow-league-tokens:latest` to start new container using the image. 101 | * `docker container ls` to verify if container is running 102 | * `docker logs ow-league-tokens` to view container's logs 103 | 104 | ## Known Issues 105 | ### Google may log you out of an account 106 | It just may happen to you. 107 | If you restart the app and see green "Authentication check has been passed" text, then everything is OK. 108 | 109 | ### Bot is watching ALL owl streams, not just those which give tokens 110 | At the current state, bot will watch ALL streams on OWL channel, no matter if they give tokens or not. 111 | I may implement watching only token-giving streams in the future. 112 | 113 | ### Anything else? 114 | Then [open new issue](https://github.com/ucarno/ow-league-tokens/issues/new) and I will look into this. 115 | 116 | ## Contribution 117 | Feel free to contribute by 118 | [opening new issue](https://github.com/ucarno/ow-league-tokens/issues/new), 119 | [making a pull request](https://github.com/ucarno/ow-league-tokens/pulls) or 120 | [buying me a coffee](https://ko-fi.com/ucarno). 121 | Thanks to everyone for using this bot, contributing, leaving feedback and 122 | helping other people in [our Discord](https://discord.gg/kkq2XY4cJM)! 123 | 124 | ## Update History 125 | ### v2.0.6 126 | _Thanks [1Gzy](https://github.com/1Gzy) for pull request and implementation ideas!_ 127 | * Fixes [this](https://github.com/ucarno/ow-league-tokens/issues/85) issue by not relying on live embed url. 128 | * Added experimental schedule mode based on [this](https://overwatchleague.com/en-us/schedule) schedule from OWL website. 129 | This option will fall back to checking stream status using new method if something goes wrong. 130 | 131 | ### v2.0.5 132 | * Added option to shut down PC after stream (requires root on Linux) 133 | * Fixed macOS certificate setup 134 | 135 | ### v2.0.4 136 | * Fixed stuck Brave browser headless windows not closing on app start 137 | * "Fixed" some weird non-descriptive errors from crashing app by restarting the entire app when it crashes. 138 | _Probably need to migrate to Playwright to actually solve these issues._ 139 | * Finally added Docker support (good luck) 140 | * Added build scripts for Windows, Linux and macOS 141 | * Disabled `HardwareMediaKeyHandling` feature which captured hardware media key presses 142 | (you need to delete your `config.json` file for this to take effect). 143 | * Executables are now shipped with new sick icon: ![Overwatch League Tokens](assets/icon.ico) 144 | 145 | ### v2.0.3 146 | * _(Probably)_ Fixed a crash when trying to run multiple headless profiles 147 | * Fixed an issue when app would not start if there are headless Chrome windows left from previous run (Windows only) 148 | * Now showing better error when browser executable can't be found 149 | ([related](https://github.com/ultrafunkamsterdam/undetected-chromedriver/issues/497)) 150 | 151 | ### v2.0.2 152 | * App now waits for `Enter` key press after exception (can be disabled via `--nowait` argument) 153 | * Fixed issue with app crashing when Chrome is not the last version 154 | * Stream will now be refreshed every 15 minutes in case it crashes 155 | * Added experimental support of other Chromium-based browsers (via `chromium_binary` field in `config.json`) 156 | * Chromium flags can be now modified using `chromium_flags` field in `config.json` 157 | 158 | ### v2.0.1 159 | * Improved menu experience 160 | * Minor fixes 161 | 162 | ### v2.0.0 — The "YouTube" Update 163 | * Bot now works through YouTube 164 | 165 | ### v1.x.x 166 | * [Legacy version](https://github.com/ucarno/ow-league-tokens/tree/legacy) worked using OWL website 167 | 168 | ## Disclaimer 169 | This app is not affiliated with Blizzard Entertainment, Inc. All trademarks are the properties of their respective owners. 170 | 171 | 2023 Blizzard Entertainment, Inc. All rights reserved. 172 | -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ucarno/ow-league-tokens/4f4f37e509278e04d9ac5a876d19ae167bf6bb34/assets/icon.icns -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ucarno/ow-league-tokens/4f4f37e509278e04d9ac5a876d19ae167bf6bb34/assets/icon.ico -------------------------------------------------------------------------------- /dev_tools/build-linux.sh: -------------------------------------------------------------------------------- 1 | # NB! YOU CAN BUILD FOR LINUX **ONLY** UNDER LINUX - USE PYTHON 3.11.X 2 | 3 | # root directory 4 | cd .. # / 5 | 6 | # venv 7 | source venv/bin/activate 8 | 9 | # output directory + cleanup 10 | mkdir -p dist 11 | rm -rf dist/* 12 | 13 | # !! actual build !! 14 | pyinstaller ./src/main.py --noconfirm --name ow-league-tokens --runtime-hook="./hooks/use_lib.py" 15 | 16 | # make 'lib' structure 17 | cd dist # /dist 18 | mv ow-league-tokens lib 19 | mkdir ow-league-tokens 20 | mv lib ow-league-tokens/lib 21 | cd ow-league-tokens # /dist/ow-league-tokens 22 | 23 | # these files are required (in root directory) for the app to start 24 | mv lib/ow-league-tokens . 25 | mv lib/base_library.zip . 26 | mv lib/lib-dynload . 27 | mv lib/libpython3.11.so.1.0 . 28 | 29 | cd .. # /dist 30 | zip -9 -rXq ow-league-tokens_Linux.zip ow-league-tokens 31 | -------------------------------------------------------------------------------- /dev_tools/build-macos.sh: -------------------------------------------------------------------------------- 1 | # NB! YOU CAN BUILD FOR MACOS **ONLY** UNDER MACOS - USE PYTHON 3.11.X 2 | 3 | # root directory 4 | cd .. # / 5 | 6 | # venv 7 | source venv/bin/activate 8 | 9 | # output directory + cleanup 10 | mkdir -p dist 11 | rm -rf dist/* 12 | 13 | # !! actual build !! 14 | pyinstaller ./src/main.py --noconfirm --name ow-league-tokens --icon=".\assets\icon.icns" --runtime-hook="./hooks/use_lib.py" 15 | 16 | # make 'lib' structure 17 | cd dist # /dist 18 | mv ow-league-tokens lib 19 | mkdir ow-league-tokens 20 | mv lib ow-league-tokens/lib 21 | cd ow-league-tokens # /dist/ow-league-tokens 22 | 23 | # these files are required (in root directory) for the app to start 24 | mv lib/ow-league-tokens . 25 | mv lib/base_library.zip . 26 | mv lib/lib-dynload . 27 | mv lib/libssl.1.1.dylib . 28 | mv lib/libcrypto.1.1.dylib . 29 | mv lib/Python . 30 | 31 | cd .. # /dist 32 | zip -9 -rXq ow-league-tokens_macOS.zip ow-league-tokens 33 | -------------------------------------------------------------------------------- /dev_tools/build-windows.sh: -------------------------------------------------------------------------------- 1 | # NB! YOU CAN BUILD FOR WINDOWS **ONLY** UNDER WINDOWS - USE PYTHON 3.11.X 2 | 3 | # root directory 4 | cd .. # / 5 | 6 | # venv 7 | source venv/Scripts/activate 8 | 9 | # output directory + cleanup 10 | mkdir -p dist 11 | rm -rf dist/* 12 | 13 | # !! actual build !! 14 | pyinstaller .\\src\\main.py --noconfirm --name ow-league-tokens --icon="./assets/icon.ico" --runtime-hook=".\hooks\use_lib.py" 15 | 16 | # make 'lib' structure 17 | cd dist # /dist 18 | mv ow-league-tokens lib 19 | mkdir ow-league-tokens 20 | mv lib ow-league-tokens/lib 21 | cd ow-league-tokens # /dist/ow-league-tokens 22 | 23 | # these files are required (in root directory) for the app to start 24 | mv lib/ow-league-tokens.exe . 25 | mv lib/base_library.zip . 26 | mv lib/python311.dll . 27 | 28 | echo "ow-league-tokens.exe --nomenu" > Start_Without_Menu.bat 29 | 30 | cd .. # /dist 31 | powershell Compress-Archive ow-league-tokens ow-league-tokens_Windows.zip 32 | -------------------------------------------------------------------------------- /dev_tools/win-launcher.cmd: -------------------------------------------------------------------------------- 1 | ..\venv\Scripts\activate&cd ..\src&title ow-league-tokens Farmer&python main.py -------------------------------------------------------------------------------- /dev_tools/win-venv.cmd: -------------------------------------------------------------------------------- 1 | start cmd /k "..\venv\Scripts\activate&cd ..&title Venv" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | ow-league-tokens: 4 | environment: ['DISPLAY=:99'] 5 | volumes: ['./src/profiles:/app/src/profiles'] 6 | build: . 7 | # entrypoint: ['sleep', '10000'] # just for testing 8 | entrypoint: ['/bin/sh', '-c', './docker-entrypoint.sh'] 9 | restart: unless-stopped 10 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | Xvfb $DISPLAY -screen $DISPLAY 1280x1024x16 & export DISPLAY=:99 3 | python ./src/main.py --nomenu --nowait --docker --profiles default 4 | -------------------------------------------------------------------------------- /hooks/use_lib.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # http://unafaltadecomprension.blogspot.com/2014/07/pyinstaller-separating-executable-from.html 5 | sys.path.append(os.path.join(os.path.dirname(sys.argv[0]), 'lib')) 6 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pyinstaller==5.13.0 2 | charset-normalizer==2.1.0 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorama 2 | requests 3 | undetected-chromedriver==3.4.6 4 | selenium==4.9.0 5 | certifi==2023.5.7 6 | -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import logging 3 | import traceback 4 | from random import randint 5 | from time import sleep, time 6 | 7 | from selenium.common.exceptions import WebDriverException 8 | import selenium.webdriver.support.expected_conditions as EC # noqa 9 | from selenium.webdriver.common.by import By 10 | from selenium.webdriver.support.wait import WebDriverWait 11 | import undetected_chromedriver as uc 12 | 13 | from constants import YOUTUBE_LOGIN_URL, YOUTUBE_AUTH_PASS, YOUTUBE_AUTH_FAIL, YOUTUBE_AUTH_ANY_RE, \ 14 | OWL_CHANNEL_ID, PATH_PROFILES, OWC_CHANNEL_ID, YOUTUBE_AUTH_PASS_RE, STREAM_CHECK_FREQUENCY, NEW_TAB_URL, \ 15 | DISCORD_URL, ISSUES_URL 16 | from utils import log_error, log_info, log_debug, get_active_stream, is_debug, check_for_new_version, set_debug, \ 17 | make_debug_file, get_console_message, set_nowait, wait_before_finish, kill_headless_chromes, shut_down_pc, get_seconds_till_next_match 18 | 19 | error = lambda msg: log_error(f'Bot', msg) 20 | info = lambda msg: log_info(f'Bot', msg) 21 | debug = lambda msg: log_debug(f'Bot', msg) 22 | 23 | driver_error = lambda _driver, msg: log_error(f'Chrome - \'{get_driver_profile(_driver)}\'', msg) 24 | driver_info = lambda _driver, msg: log_info(f'Chrome - \'{get_driver_profile(_driver)}\'', msg) 25 | driver_debug = lambda _driver, msg: log_debug(f'Chrome - \'{get_driver_profile(_driver)}\'', msg) 26 | 27 | DRIVERS: list[uc.Chrome] = [] 28 | CURRENT_VERSION_MAIN = None 29 | 30 | 31 | def get_driver_profile(driver: uc.Chrome) -> str: 32 | return getattr(driver, '__profile_name') 33 | 34 | 35 | def get_chrome_options(config: dict) -> uc.ChromeOptions: 36 | options = uc.ChromeOptions() 37 | for argument in config['chromium_flags']: 38 | options.add_argument(argument) 39 | 40 | if config['chromium_binary']: 41 | options.binary_location = config['chromium_binary'] 42 | 43 | return options 44 | 45 | 46 | def get_driver(profile: str, config: dict) -> uc.Chrome: 47 | global CURRENT_VERSION_MAIN 48 | 49 | kwargs = { 50 | 'options': get_chrome_options(config), 51 | 'user_data_dir': PATH_PROFILES.joinpath(profile).absolute(), 52 | 'headless': config['headless'], 53 | 'log_level': 1 if is_debug() else 0, 54 | 'version_main': CURRENT_VERSION_MAIN, 55 | } 56 | 57 | try: 58 | # try to create a driver with the latest ChromeDriver version 59 | driver = uc.Chrome(**kwargs) 60 | except WebDriverException as e: 61 | message = e.msg 62 | src = 'Driver' 63 | 64 | check_version, check_different_browser = [ 65 | 'This version of ChromeDriver only supports Chrome version' in message, 66 | 'unrecognized Chrome version' in message, 67 | ] 68 | if check_version or check_different_browser: 69 | if check_version: 70 | log_info(src, f'ChromeDriver version differs from installed Chrome version. Trying to fix that!') 71 | 72 | try: 73 | CURRENT_VERSION_MAIN = int(message.split('Current browser version is ')[1].rstrip().split('.')[0]) 74 | except Exception as e: 75 | log_error(src, f'Could not fix that, can\'t get correct Chrome version: {str(e)}') 76 | make_debug_file('driver-version', message, True) 77 | wait_before_finish() 78 | exit(1) 79 | else: 80 | log_info(src, 'Unrecognized Chrome version. Are you using different browser? Trying to fix that!') 81 | 82 | try: 83 | if 'unrecognized Chrome version: 1.0.0' in message: 84 | browser, version = 'Opera', None 85 | CURRENT_VERSION_MAIN = None 86 | log_error('Opera is not supported, install either Google Chrome or Brave.') 87 | wait_before_finish() 88 | exit(1) 89 | else: 90 | browser_info = message.split('unrecognized Chrome version: ')[1] 91 | browser, version = browser_info.split('/') 92 | version_main = int(version.split('.')[0]) 93 | CURRENT_VERSION_MAIN = version_main 94 | 95 | if browser == 'Edg': 96 | log_error('MS Edge is not supported, install either Google Chrome or Brave.') 97 | wait_before_finish() 98 | exit(1) 99 | # https://msedgewebdriverstorage.z22.web.core.windows.net/ 100 | # todo: if edge, use EdgeDriver instead 101 | except Exception as e: 102 | log_error(src, f'Could not fix that, can\'t get correct Chrome version: {str(e)}') 103 | make_debug_file('driver-version-other-browser', message, True) 104 | wait_before_finish() 105 | exit(1) 106 | 107 | log_info(src, f'&gSuccessfully got correct Chromium version ({CURRENT_VERSION_MAIN}) ' 108 | f'and browser ({browser})!') 109 | log_info(src, 'Trying to boot ChromeDriver with correct version instead...') 110 | 111 | kwargs['version_main'] = CURRENT_VERSION_MAIN 112 | kwargs['options'] = get_chrome_options(config) # options can't be reused 113 | 114 | try: 115 | driver = uc.Chrome(**kwargs) 116 | except Exception as e: 117 | tb = traceback.format_exc() 118 | log_error(src, tb) 119 | print('\n') 120 | log_error('Could not boot your browser.') 121 | exit(1) 122 | 123 | else: 124 | raise e 125 | except TypeError as e: 126 | if 'expected str, bytes or os.PathLike object, not NoneType' in str(e): 127 | error('Can\'t find browser executable location. &ySpecify it in config.json (field "chromium_binary"). ' 128 | 'You can get it from this url: chrome://version (search for \'Executable Path\'). ' 129 | 'https://discord.com/channels/1103710176189628429/1103734357031653376/1104075303871062158') 130 | wait_before_finish() 131 | exit(1) 132 | raise e 133 | 134 | driver.set_window_size(1200, 800) 135 | setattr(driver, '__profile_name', profile) 136 | 137 | return driver 138 | 139 | 140 | def watch_broadcast(driver: uc.Chrome, url: str): 141 | driver_info(driver, 'Driver is going to stream at ' + url + '...') 142 | driver.get(url) 143 | # todo: find a way to change quality without relying on English labels 144 | # driver.implicitly_wait(3) 145 | # setting = driver.find_element(By.CLASS_NAME, 'ytp-settings-button') 146 | # setting.click() 147 | # 148 | # # Click quality button 149 | # driver.implicitly_wait(3) 150 | # quality = driver.find_element(By.XPATH, '//div[@class="ytp-menuitem"]/div[text()="Quality"]') 151 | # quality.click() 152 | # 153 | # # Click 720p 154 | # sleep(0.5) 155 | # quality = driver.find_element_by_xpath("//span[contains(string(),'144p')]") 156 | # quality.click() 157 | 158 | 159 | def start_chrome(config: dict): 160 | global DRIVERS, CURRENT_VERSION_MAIN 161 | 162 | info(f'&yBooting {len(config["profiles"])} {"headless " if config["headless"] else ""}Chrome driver(s)...') 163 | if not config['headless']: 164 | info('&yFollow all instructions you see here and &rDO NOT &ypress or ' 165 | 'do anything until asked, it may break the bot.') 166 | 167 | kill_headless_chromes() 168 | 169 | live_check_driver = get_driver('live-check', config) 170 | 171 | drivers = [] 172 | for index, profile in enumerate(config['profiles']): 173 | drivers.append(get_driver(profile, config)) 174 | if index == 0: 175 | CURRENT_VERSION_MAIN = drivers[0].patcher.version_main 176 | 177 | DRIVERS = drivers 178 | 179 | for index, driver in enumerate(drivers): 180 | info('&yChecking if you are logged in...') 181 | 182 | auth_check_time = time() 183 | driver.get(YOUTUBE_LOGIN_URL) 184 | 185 | try: 186 | WebDriverWait(driver, timeout=10).until(EC.url_matches(YOUTUBE_AUTH_ANY_RE)) 187 | except WebDriverException: 188 | error(f'&rAuthentication check failed. You were not meant to be on URL "{driver.current_url}".') 189 | driver.quit() 190 | 191 | if driver.current_url.startswith(YOUTUBE_AUTH_FAIL): 192 | if config['headless']: 193 | driver_error(driver, '&rAuthentication check failed. ' 194 | '&mPlease run this app as &rNOT headless&m and log in to your Google account.') 195 | 196 | for _driver in drivers: 197 | _driver.quit() 198 | 199 | wait_before_finish() 200 | exit() 201 | 202 | else: 203 | driver_info(driver, '&rAuthentication check failed. ' 204 | '&mPlease log in to your Google account. If you don\'t see Google\'s login screen, ' 205 | 'then sync your Google Chrome profile using profile button located in upper right ' 206 | 'corner of a browser and go manually to https://www.youtube.com/ ' 207 | 'Please leave your browser open for a couple of minutes after logging in to make ' 208 | 'sure your session persists. You have 5000 seconds for that.\n') 209 | 210 | WebDriverWait(driver, 5000).until(EC.url_matches(YOUTUBE_AUTH_PASS_RE)) 211 | driver.get(NEW_TAB_URL) 212 | 213 | elif driver.current_url.startswith(YOUTUBE_AUTH_PASS): 214 | driver_info(driver, '&gAuthentication check passed.') 215 | driver.get(NEW_TAB_URL) 216 | 217 | # if not last driver, then add a little delay so Google doesn't think you are suspicious 218 | if index != (len(drivers) - 1) and (time() - auth_check_time) < 10: 219 | delay = round(time() - auth_check_time) + 1 220 | info(f'Looks like there are more drivers. Adding {delay} seconds delay before checking for ' 221 | 'authentication so Google doesn\'t complain about suspicious activity.') 222 | sleep(delay) 223 | 224 | info('Setting stream status as &rOffline&y by default. Started looking for live stream...') 225 | 226 | live_url = None 227 | live_src = None 228 | checks_until_reload = 3 229 | sleep_schedule = 0 230 | 231 | while True: 232 | skip_owc_check = False 233 | 234 | current_url = None 235 | current_src = None 236 | 237 | info('Checking for stream status...') 238 | 239 | if config['enable_owl']: 240 | url = get_active_stream(OWL_CHANNEL_ID, live_check_driver) 241 | if url: 242 | debug('&gOWL stream is online!') 243 | current_url, current_src = url, 'OWL' 244 | skip_owc_check = True 245 | elif config['schedule']: 246 | info('Considering schedule due to stream being offline...') 247 | seconds = get_seconds_till_next_match() 248 | if seconds and seconds > 30 * 60: 249 | info('Schedule approved! Bot will start checking stream manually 30 min before the scheduled match.') 250 | sleep_schedule = seconds - 30 * 60 251 | else: 252 | info('Schedule is not an option.') 253 | 254 | if config['enable_owc'] and not skip_owc_check: 255 | url = get_active_stream(OWC_CHANNEL_ID, live_check_driver) 256 | if url: 257 | debug('&gOWC stream is online!') 258 | current_url, current_src = url, 'OWC' 259 | 260 | if current_url != live_url: 261 | checks_until_reload = 3 262 | if current_url: 263 | if live_url: 264 | info('Stream URL has changed?') 265 | else: 266 | info('&gStream has just started!') 267 | 268 | for index, driver in enumerate(drivers): 269 | watch_broadcast(driver, current_url) 270 | if index != (len(drivers) - 1): 271 | delay = randint(5, 15) 272 | info(f'Looks like there are more drivers. Adding random {delay} seconds delay before going to ' 273 | f'live stream.') 274 | sleep(delay) 275 | else: 276 | info('&rStream has just ended :( &cTrack your token earning progress here: ' 277 | 'https://account.battle.net/transactions/ecosystem/1/5272175') 278 | for driver in drivers: 279 | driver.get(NEW_TAB_URL) 280 | 281 | if config['shut_down']: 282 | info('&rTurning this PC off... &yHit Ctrl+C to cancel!') 283 | cleanup() 284 | shut_down_pc() 285 | 286 | else: 287 | # nothing changed! 288 | if current_url: 289 | info('Nothing changed, stream still &gOnline&y!') 290 | 291 | checks_until_reload -= 1 292 | 293 | if checks_until_reload == 0: 294 | info('Time for drivers to refresh streams...') 295 | for index, driver in enumerate(drivers): 296 | driver_info(driver, 'Driver is refreshing stream page...') 297 | driver.refresh() 298 | if index != (len(drivers) - 1): 299 | delay = randint(5, 15) 300 | info(f'Looks like there are more drivers. Adding random {delay} seconds ' 301 | f'delay before refreshing live stream.') 302 | sleep(delay) 303 | checks_until_reload = 3 304 | else: 305 | info('Nothing changed, stream still &rOffline&y!') 306 | 307 | live_url, live_src = current_url, current_src 308 | if sleep_schedule: 309 | info(f'Bot is going to sleep for {round(sleep_schedule)} seconds due to enabled schedule option.') 310 | sleep(sleep_schedule) 311 | sleep_schedule = 0 312 | 313 | sleep(STREAM_CHECK_FREQUENCY + randint(0, 60)) 314 | 315 | 316 | def cleanup(): 317 | if DRIVERS: 318 | log_info('Cleanup', 'Cleaning up...') 319 | for driver in DRIVERS: 320 | try: 321 | driver.quit() 322 | except: 323 | pass 324 | 325 | 326 | def bootstrap(config: dict, nowait: bool = False): 327 | print(get_console_message( 328 | f'&mJoin our Discord for help, updates, suggestions, instructions and more: &g{DISCORD_URL}' 329 | )) 330 | 331 | logging.basicConfig( 332 | level=logging.DEBUG if config['debug'] else logging.INFO, 333 | format='[%(asctime)s - %(levelname)s] %(message)s' 334 | ) 335 | 336 | set_debug(config['debug']) 337 | set_nowait(nowait) 338 | 339 | check_for_new_version() 340 | 341 | src = 'Bootstrap' 342 | 343 | if len(config['profiles']) == 0: 344 | log_error(src, 'No profiles specified!') 345 | wait_before_finish() 346 | exit(1) 347 | elif not any((config['enable_owl'], config['enable_owc'])): 348 | log_error(src, 'Enable either OWL, OWC or both!') 349 | wait_before_finish() 350 | exit(1) 351 | 352 | atexit.register(cleanup) 353 | src = 'Oops' 354 | first_start = time() 355 | last_crash = 0 356 | 357 | while True: 358 | try: 359 | start_chrome(config) 360 | except Exception as e: 361 | content = str(e) + '\n\n' + traceback.format_exc() 362 | 363 | if 'no such window: target window already closed' in content: 364 | log_error('Bot', 'You closed Chrome window, app can\'t work without it. ' 365 | 'If you don\'t want to see it, then ¥able headless mode&r in menu.') 366 | wait_before_finish() 367 | exit(1) 368 | 369 | path = make_debug_file('unexpected-error', content, True) 370 | 371 | print(content) 372 | 373 | log_error(src, f'\n\n&rSomething unexpected happened!\n\n' 374 | f'&mFollow these steps and check if app works after each. ' 375 | f'These actions will fix 99% of problems.' 376 | f'\n\n' 377 | f'&c1. UPDATE YOUR BROWSER BY GOING TO `&gchrome://help&c`\n' 378 | f'2. DELETE `&gprofiles/&c` FOLDER\n' 379 | f'3. RESTART YOUR PC' 380 | f'\n\n' 381 | f'&rIf these steps didn\'t help, then share your issue in Discord: &y{DISCORD_URL}&r ' 382 | f'or open a GitHub issue: &y{ISSUES_URL}&r\n\n' 383 | f'Also, please include this file: &y{str(path.absolute())}&r') 384 | 385 | cleanup() 386 | 387 | seconds_since_start = time() - first_start 388 | seconds_since_last_crash = time() - last_crash 389 | 390 | last_crash = time() 391 | 392 | if seconds_since_last_crash < 180: 393 | log_info(src, f'Won\'t try to resurrect app since ' 394 | f'it crashed after only {seconds_since_last_crash} seconds since last crash.') 395 | wait_before_finish() 396 | exit(1) 397 | 398 | if seconds_since_start < 600: 399 | log_info(src, f'Won\'t try to resurrect app since ' 400 | f'it crashed after only {seconds_since_start} seconds since start.') 401 | wait_before_finish() 402 | exit(1) 403 | 404 | log_info(src, 'Trying to resurrect the app...') 405 | continue 406 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from colorama import Fore 4 | 5 | 6 | CURRENT_VERSION = '2.0.6' 7 | UPDATE_DOWNLOAD_URL = 'https://github.com/ucarno/ow-league-tokens/releases/latest' 8 | DISCORD_URL = 'https://discord.gg/kkq2XY4cJM' 9 | ISSUES_URL = 'https://github.com/ucarno/ow-league-tokens/issues' 10 | FAKE_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36' 11 | 12 | DEBUG_ENVIRON = 'OW_LEAGUE_TOKENS_DEBUG' 13 | NOWAIT_ENVIRON = 'OW_LEAGUE_TOKENS_NOWAIT' 14 | 15 | PATH_ROOT = Path(__file__).parent 16 | PATH_PROFILES = PATH_ROOT.joinpath('profiles') 17 | PATH_DEBUG = PATH_ROOT.joinpath('debug') 18 | PATH_CONFIG = PATH_ROOT.joinpath('config.json') 19 | PATH_STATS = PATH_ROOT.joinpath('stats.json') 20 | 21 | TEST_CHANNEL_ID = 'UCaG0IHN1RMOZ4-U3wDXAkwA' 22 | OWL_CHANNEL_ID = 'UCiAInBL9kUzz1XRxk66v-gw' 23 | # OWL_CHANNEL_ID = TEST_CHANNEL_ID 24 | OWC_CHANNEL_ID = 'UCWPW0pjx6gncOEnTW8kYzrg' 25 | 26 | SCHEDULE_URL = 'https://overwatchleague.com/en-us/schedule' 27 | 28 | TMPL_LIVE_STREAM_EMBED_URL = 'https://www.youtube.com/embed/live_stream?channel=%s' 29 | TMPL_LIVE_STREAM_URL = 'https://www.youtube.com/watch?v=%s' 30 | 31 | VERSION_CHECK_URL = 'https://raw.githubusercontent.com/ucarno/ow-league-tokens/main/version.txt' 32 | VERSION_ENVIRON = 'OW_LEAGUE_TOKENS_NEW_VERSION' 33 | 34 | YOUTUBE_LOGIN_URL = 'https://accounts.google.com/ServiceLogin?service=youtube&continue=https%3A%2F%2Fwww.youtube.com' 35 | 36 | YOUTUBE_AUTH_PASS = 'https://www.youtube.com' 37 | YOUTUBE_AUTH_FAIL = 'https://accounts.google.com' 38 | 39 | YOUTUBE_AUTH_PASS_RE = YOUTUBE_AUTH_PASS.replace('/', r'\/') 40 | YOUTUBE_AUTH_FAIL_RE = YOUTUBE_AUTH_FAIL.replace('/', r'\/') 41 | YOUTUBE_AUTH_ANY_RE = f'^({YOUTUBE_AUTH_PASS_RE}|{YOUTUBE_AUTH_FAIL_RE})' 42 | YOUTUBE_AUTH_PASS_RE = '^' + YOUTUBE_AUTH_PASS_RE 43 | 44 | NEW_TAB_URL = 'chrome://new-tab-page/' 45 | STREAM_CHECK_FREQUENCY = 300 # seconds 46 | 47 | DEFAULT_CHROMIUM_FLAGS = [ 48 | '--autoplay-policy=no-user-gesture-required', 49 | '--disable-extensions', 50 | '--mute-audio', 51 | '--disable-features=Translate,HardwareMediaKeyHandling', 52 | ] 53 | DOCKER_CHROMIUM_FLAGS = [ 54 | '--disable-application-cache', 55 | '--disable-gpu', 56 | '--disable-setuid-sandbox', 57 | '--disable-dev-shm-usage', 58 | ] 59 | 60 | COLORS = ( 61 | ('&g', Fore.GREEN), 62 | ('&r', Fore.RED), 63 | ('&c', Fore.CYAN), 64 | ('&y', Fore.YELLOW), 65 | ('&m', Fore.MAGENTA), 66 | ('&!r', Fore.RESET), 67 | ) 68 | -------------------------------------------------------------------------------- /src/macos.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ssl 3 | import stat 4 | from pathlib import Path 5 | 6 | STAT_0o775 = ( 7 | stat.S_IRUSR | stat.S_IWUSR | 8 | stat.S_IXUSR | stat.S_IRGRP | 9 | stat.S_IWGRP | stat.S_IXGRP | 10 | stat.S_IROTH | stat.S_IXOTH 11 | ) 12 | 13 | 14 | def setup_macos_certs(): 15 | openssl_path = Path(ssl.get_default_verify_paths().openssl_cafile) 16 | openssl_dir = openssl_path.parent 17 | 18 | # make sure directory exists 19 | openssl_dir.mkdir(parents=True, exist_ok=True) 20 | 21 | # removing any existing file or link 22 | openssl_path.unlink(True) 23 | 24 | # creating symlink to certifi certificate bundle 25 | cert_file = Path(os.environ['REQUESTS_CA_BUNDLE']) 26 | openssl_path.symlink_to(cert_file) 27 | 28 | # setting permissions 29 | openssl_path.chmod(STAT_0o775) 30 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | 5 | from colorama import init 6 | 7 | from app import bootstrap 8 | from constants import PATH_PROFILES, PATH_DEBUG, PATH_ROOT, DISCORD_URL, DOCKER_CHROMIUM_FLAGS 9 | from macos import setup_macos_certs 10 | from menu import menu 11 | from utils import load_config, get_console_message 12 | 13 | if __name__ == '__main__': 14 | # fix requests module not able to find certs in lib folder 15 | if hasattr(sys, 'frozen'): 16 | os.environ['REQUESTS_CA_BUNDLE'] = ( 17 | str(PATH_ROOT.joinpath('lib').joinpath('certifi').joinpath('cacert.pem').absolute()) 18 | ) 19 | 20 | if sys.platform.startswith('darwin'): 21 | check_path = PATH_ROOT.joinpath('macos_certs.txt') 22 | if not check_path.exists(): 23 | setup_macos_certs() 24 | with open(check_path, 'w+', encoding='utf-8') as f: 25 | f.write('This files indicates that CA Certificates are set up.') 26 | f.close() 27 | 28 | init() 29 | print(get_console_message( 30 | f'&mJoin our Discord for help, updates, suggestions, instructions and more: &g{DISCORD_URL}' 31 | )) 32 | 33 | for path in (PATH_PROFILES, PATH_DEBUG): 34 | path.mkdir(parents=True, exist_ok=True) 35 | 36 | parser = argparse.ArgumentParser(description='Help of ow-league-tokens') 37 | parser.add_argument( 38 | '--nomenu', 39 | help='Run app without menu using config', 40 | action='store_true', 41 | ) 42 | parser.add_argument( 43 | '--nowait', 44 | help='App will not wait for Enter key press on error', 45 | action='store_true', 46 | ) 47 | parser.add_argument( 48 | '--docker', 49 | help='Specifying this argument will include additional Chromium flags that will make Docker work ' 50 | '(works only with `--nomenu` argument)', 51 | action='store_true', 52 | ) 53 | parser.add_argument( 54 | '--profiles', 55 | help='Specify profiles to use instead of taking them from `config.json` (works only with `--nomenu` argument)', 56 | nargs='+', 57 | type=str, 58 | default=[], 59 | ) 60 | 61 | args = parser.parse_args() 62 | 63 | if args.nomenu: 64 | config = load_config() 65 | if args.profiles: 66 | config['profiles'] = args.profiles 67 | if args.docker: 68 | config['chromium_flags'].extend(DOCKER_CHROMIUM_FLAGS) 69 | config['headless'] = False 70 | bootstrap(config, args.nowait) 71 | else: 72 | menu() 73 | -------------------------------------------------------------------------------- /src/menu.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from app import bootstrap 4 | from constants import PATH_PROFILES 5 | from utils import get_console_message, load_config, save_config 6 | 7 | 8 | P = lambda msg: print(get_console_message('&y' + msg)) 9 | cls = lambda: os.system('cls' if os.name == 'nt' else 'clear') 10 | wait_for_enter = lambda: input('\nPress Enter...\n') 11 | get_input = lambda: input(' >> ') 12 | 13 | 14 | def add_profile(config): 15 | cls() 16 | 17 | allowed_characters = set(list('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_')) 18 | allowed_chars_repr = '&gA-Z&r, &ga-z&r, &g0-9&r, &g-&r, &g_&r' 19 | P('One account - one profile. Enter whatever you want — profile name is only for convenience.\n' 20 | 'I recommend adding one account at a time so you know what account you log into.') 21 | while True: 22 | P(f'Enter profile name (allowed characters: {allowed_chars_repr.replace("&r", "&y")}) or leave blank to exit:') 23 | name = get_input() 24 | 25 | if not name: 26 | return 27 | 28 | if len(set(list(name)) - allowed_characters) > 0: 29 | P(f'&rProfile name contains forbidden characters! Allowed characters are {allowed_chars_repr}&r.\n') 30 | continue 31 | 32 | if name in config['profiles']: 33 | P(f'&rProfile \'{name}\' already exists!\n') 34 | continue 35 | 36 | config['profiles'].append(name) 37 | save_config(config) 38 | P('&gProfile added!\n') 39 | continue 40 | 41 | 42 | def view_profiles(config): 43 | cls() 44 | 45 | if len(config['profiles']) == 0: 46 | P('&rNo profiles!') 47 | wait_for_enter() 48 | 49 | P('Your profiles:') 50 | for profile in config['profiles']: 51 | P(f' &c{profile}') 52 | 53 | wait_for_enter() 54 | 55 | 56 | def remove_profile(config): 57 | if len(config['profiles']) == 0: 58 | P('&rNo profiles!') 59 | wait_for_enter() 60 | return 61 | 62 | cls() 63 | 64 | P('You are gonna remove your profile. Profile will not be deleted (just disabled), you can re-add it later. ' 65 | f'To remove profile completely, delete profile from &g{PATH_PROFILES.absolute()}&y directory.') 66 | 67 | while True: 68 | if len(config['profiles']) == 0: 69 | return 70 | 71 | P('\nYour profiles:') 72 | for index, profile in enumerate(config['profiles']): 73 | P(f' {index + 1}. &c{profile}') 74 | 75 | P('\nChoose profile to remove (leave blank to exit):') 76 | profile_idx = get_input() 77 | if not profile_idx: 78 | return 79 | 80 | profile_count = len(config['profiles']) 81 | if not profile_idx.isdigit() or not 0 < int(profile_idx) <= profile_count: 82 | P('\n&rInvalid profile number!') 83 | continue 84 | 85 | profile_idx = int(profile_idx) - 1 86 | config['profiles'].pop(profile_idx) 87 | save_config(config) 88 | P('\n&gProfile removed!') 89 | 90 | if len(config['profiles']) == 0: 91 | return 92 | 93 | 94 | def switch_setting(config, setting): 95 | config[setting] = not config[setting] 96 | save_config(config) 97 | 98 | 99 | def menu(): 100 | config = load_config() 101 | first_run = True 102 | 103 | while True: 104 | options = [ 105 | ( 106 | f'Start app!', 107 | lambda c: (cls(), bootstrap(c)) 108 | ), 109 | ( 110 | f'Add profile &g[+]', 111 | add_profile 112 | ), 113 | ( 114 | f'Remove profile &r[{"-" if len(config["profiles"]) else "-"}]', 115 | remove_profile, 116 | ), 117 | ( 118 | f'View profiles {"&g" if len(config["profiles"]) else "&r"}[{len(config["profiles"])} profile(s)]', 119 | view_profiles 120 | ), 121 | ( 122 | f'Switch headless mode {"&g[enabled]" if config["headless"] else "&r[disabled]"}', 123 | lambda c: switch_setting(c, 'headless') 124 | ), 125 | ( 126 | f'Switch schedule mode {"&g[enabled]" if config["schedule"] else "&r[disabled]"} &m(experimental!)', 127 | lambda c: switch_setting(c, 'schedule') 128 | ), 129 | ( 130 | f'Switch debug mode {"&g[enabled]" if config["debug"] else "&r[disabled]"}', 131 | lambda c: switch_setting(c, 'debug') 132 | ), 133 | ( 134 | f'Turn off PC when stream ends {"&g[enabled]" if config["shut_down"] else "&r[disabled]"}', 135 | lambda c: switch_setting(c, 'shut_down') 136 | ), 137 | ( 138 | f'Exit', 139 | lambda c: exit() 140 | ), 141 | ] 142 | 143 | if first_run: 144 | first_run = False 145 | else: 146 | cls() 147 | 148 | P('&cSelect an option:') 149 | for index, (option_name, _) in enumerate(options): 150 | P(f' &c{index + 1}. &y{option_name}') 151 | 152 | option = get_input() 153 | 154 | if not option or not option.isdigit() or not 0 < int(option) <= len(options): 155 | P('&rInvalid option!') 156 | wait_for_enter() 157 | 158 | option_idx = int(option) - 1 159 | option_callback = options[option_idx][1] 160 | option_callback(config) # noqa 161 | 162 | continue 163 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import subprocess 5 | import sys 6 | import traceback 7 | import re 8 | from datetime import datetime, timezone 9 | from pathlib import Path 10 | from time import sleep 11 | import undetected_chromedriver as uc 12 | from selenium.webdriver.common.by import By 13 | 14 | import requests 15 | 16 | from constants import ( 17 | COLORS, TMPL_LIVE_STREAM_URL, VERSION_CHECK_URL, PATH_DEBUG, CURRENT_VERSION, DEBUG_ENVIRON, PATH_CONFIG, 18 | UPDATE_DOWNLOAD_URL, NOWAIT_ENVIRON, DEFAULT_CHROMIUM_FLAGS, PATH_STATS, SCHEDULE_URL, FAKE_USER_AGENT, NEW_TAB_URL 19 | ) 20 | 21 | 22 | def get_version(version: str) -> tuple: 23 | return tuple(map(int, (version.split(".")))) 24 | 25 | 26 | def wait_before_finish(also_exit=False, exit_code=1): 27 | if not is_nowait(): 28 | input('\n\nPress Enter to close...\n') 29 | if also_exit: 30 | exit(exit_code) 31 | 32 | 33 | def get_default_config() -> dict: 34 | return { 35 | 'profiles': ['default'], 36 | 'enable_owl': True, 37 | 'enable_owc': False, 38 | 'headless': False, 39 | 'shut_down': False, 40 | 'debug': False, 41 | 'time_delta': False, 42 | 'schedule': False, 43 | 'chromium_binary': None, 44 | 'chromium_flags': DEFAULT_CHROMIUM_FLAGS, 45 | } 46 | 47 | 48 | def load_config() -> dict: 49 | if PATH_CONFIG.exists(): 50 | with open(PATH_CONFIG, 'r', encoding='utf-8') as f: 51 | content = f.read() 52 | f.close() 53 | 54 | default_config = get_default_config() 55 | content = json.loads(content) 56 | update_config = False 57 | 58 | for new_flag in ( 59 | # v2.0.2 60 | 'chromium_binary', 'chromium_flags', 61 | 62 | # v2.0.5 63 | 'shut_down', 64 | 65 | # v2.0.6 66 | 'schedule', 67 | ): 68 | if new_flag not in content: 69 | update_config = True 70 | content[new_flag] = default_config[new_flag] 71 | 72 | if update_config: 73 | save_config(content) 74 | 75 | return content 76 | else: 77 | content = get_default_config() 78 | save_config(content) 79 | return content 80 | 81 | 82 | def save_config(new_config: dict): 83 | with open(PATH_CONFIG, 'w+', encoding='utf-8') as f: 84 | f.write(json.dumps(new_config, indent=4)) 85 | f.close() 86 | 87 | 88 | def load_stats() -> list: 89 | content = [] 90 | if PATH_STATS.exists(): 91 | with open(PATH_STATS, 'r', encoding='utf-8') as f: 92 | content = json.loads(f.read()) 93 | f.close() 94 | return content 95 | 96 | 97 | def save_stats(new_stats: list): 98 | with open(PATH_STATS, 'w+', encoding='utf-8') as f: 99 | f.write(json.dumps(new_stats, indent=4)) 100 | f.close() 101 | 102 | 103 | def add_session_stats(session_stats: dict): 104 | stats = load_stats() 105 | stats.insert(0, session_stats) 106 | save_stats(stats) 107 | 108 | 109 | def shut_down_pc(): 110 | src = 'Shutdown' 111 | shutdown_command = [] 112 | 113 | if sys.platform.startswith('win32'): 114 | shutdown_command = ['shutdown', '-s', '-t', '1'] 115 | elif sys.platform.startswith('darwin') or sys.platform.startswith('linux'): 116 | shutdown_command = ['shutdown', '-h', 'now'] 117 | 118 | if not shutdown_command: 119 | log_error(src, f'Not sure how to turn off PC on platform \'{sys.platform}\'.') 120 | wait_before_finish(True) 121 | 122 | for i in reversed(range(1, 6)): 123 | log_info(src, f'&rThis PC will shut down in &y{i} &rsecond{"s" if i > 1 else ""}!') 124 | sleep(1) 125 | 126 | log_info(src, '&rShutting down...') 127 | subprocess.run(shutdown_command) 128 | 129 | 130 | def run_powershell(command): 131 | subprocess.Popen(['powershell.exe', command], stdout=sys.stdout) 132 | 133 | 134 | def kill_headless_chromes(binary_path: str | None = None): 135 | src = 'Ghostbuster' 136 | 137 | # linux: pkill -f "(chrome).*(--headless)" 138 | 139 | # https://superuser.com/questions/1288388/how-can-i-kill-all-headless-chrome-instances-from-the-command-line-on-windows 140 | if sys.platform.startswith('win32'): 141 | process_name = 'chrome.exe' 142 | if binary_path: 143 | process_name = binary_path.split('\\')[-1].split('/')[-1] 144 | 145 | log_debug(src, f'Killing stuck \'{process_name}\' processes just in case') 146 | try: 147 | run_powershell(f'Get-CimInstance Win32_Process -Filter ' 148 | f'"Name = \'{process_name}\' AND CommandLine LIKE \'%--headless%\'" | ' 149 | '%{Stop-Process $_.ProcessId}') 150 | except Exception as e: 151 | log_debug(src, f'Failed killing Chrome process(es): {str(e)}') 152 | 153 | 154 | def make_get_request(url: str): 155 | return requests.get(url, headers={'User-Agent': FAKE_USER_AGENT}, timeout=10) 156 | 157 | 158 | def get_active_stream(channel_id: str, driver: uc.Chrome | None = None) -> str | None: 159 | """Returns stream url if a channel with specified channel_id has active stream""" 160 | src = 'LiveCheck' 161 | 162 | check_url = 'https://www.youtube.com/channel/%s' % channel_id 163 | 164 | driver_failed = False 165 | 166 | if driver: 167 | log_debug(src, 'Checking stream status using WebDriver...') 168 | driver.get(check_url) 169 | if 'consent.youtube.com' in driver.current_url: 170 | log_debug(src, 'YouTube asked for consent') 171 | try: 172 | element = driver.find_element(By.XPATH, '//form[@action="https://consent.youtube.com/save"]') 173 | element.click() 174 | except: 175 | log_error(src, 'Failed to get stream status using driver!') 176 | driver_failed = True 177 | driver.get(NEW_TAB_URL) 178 | 179 | if not driver_failed: 180 | sleep(5) 181 | response = driver.execute_script("return document.getElementsByTagName('html')[0].innerHTML") 182 | driver.get(NEW_TAB_URL) 183 | 184 | if not driver or driver_failed: 185 | log_debug(src, 'Checking stream status using \'requests\'...') 186 | response = make_get_request('https://www.youtube.com/channel/%s' % channel_id).text 187 | 188 | try: 189 | if 'hqdefault_live.jpg' in response: 190 | video_id = re.search(r'vi/(.*?)/hqdefault_live.jpg', response).group(1) 191 | return TMPL_LIVE_STREAM_URL % video_id 192 | except Exception as e: 193 | log_error(src, f'&rLive stream check failed: {str(e)}') 194 | make_debug_file('failed-getting-active-stream', traceback.format_exc()) 195 | return 196 | 197 | 198 | def get_seconds_till_next_match() -> float | None: 199 | src = 'Schedule' 200 | 201 | try: 202 | # getting page json 203 | schedule_html = make_get_request(SCHEDULE_URL).text 204 | next_data = ( 205 | schedule_html 206 | .split('')[0].strip() 208 | ) 209 | schedule_json = json.loads(next_data) 210 | 211 | # getting matches 212 | blocks = schedule_json['props']['pageProps']['blocks'] 213 | matches: list = [] 214 | for block in blocks: 215 | if 'owlHeader' in block.keys(): 216 | matches = block['owlHeader']['scoreStripList']['scoreStrip']['matches'] 217 | break 218 | 219 | if not matches: 220 | raise Exception('Could not get matches.') 221 | 222 | pending_matches = list(filter(lambda match: match.get('status') == 'PENDING', matches)) 223 | pending_matches.sort(key=lambda match: match['date']['startDate']) 224 | 225 | if not pending_matches: 226 | raise Exception(f"No matches with status 'PENDING'.") 227 | 228 | timestamp_ms = pending_matches[0]['date']['startDate'] 229 | delta = datetime.fromtimestamp(timestamp_ms / 1000, timezone.utc) - datetime.now(timezone.utc) 230 | total_seconds = delta.total_seconds() 231 | 232 | log_info(src, f'Closest match will be played in {delta}.') 233 | 234 | return total_seconds 235 | except Exception as e: 236 | log_error(src, f'&rSchedule check failed: {str(e)}. Falling back to regular checks.') 237 | make_debug_file('failed-getting-schedule', traceback.format_exc()) 238 | 239 | 240 | def check_for_new_version(): 241 | log_src = 'Version' 242 | log_info(log_src, 'Checking for new version...') 243 | try: 244 | response = requests.get(VERSION_CHECK_URL, timeout=3) 245 | latest_version = response.text.strip() 246 | except Exception as e: 247 | log_error(log_src, f'&rFailed to check for new version: {str(e)}.') 248 | make_debug_file('versioncheck', traceback.format_exc()) 249 | return 250 | 251 | if response.status_code == 200 and get_version(latest_version) > get_version(CURRENT_VERSION): 252 | log_info(log_src, f'&gNew version available! You are on version &r{CURRENT_VERSION}&g, ' 253 | f'but version &m{latest_version}&g is available! Download here: &m{UPDATE_DOWNLOAD_URL}') 254 | else: 255 | log_info(log_src, 'No new version available!') 256 | 257 | 258 | def is_debug() -> bool: 259 | return os.environ.get(DEBUG_ENVIRON, 'false') == 'true' 260 | 261 | 262 | def set_debug(value: bool): 263 | os.environ.setdefault(DEBUG_ENVIRON, 'true' if value else 'false') 264 | 265 | 266 | def is_nowait() -> bool: 267 | return os.environ.get(NOWAIT_ENVIRON, 'false') == 'true' 268 | 269 | 270 | def set_nowait(value: bool): 271 | os.environ.setdefault(NOWAIT_ENVIRON, 'true' if value else 'false') 272 | 273 | 274 | def make_debug_file(name: str, content: str, force: bool = False) -> Path | None: 275 | if is_debug() or force: 276 | dt = datetime.now().replace(microsecond=0).isoformat().replace(':', '-') 277 | filename = f'{name}_{dt}.txt' 278 | path = PATH_DEBUG.joinpath(filename) 279 | log_info('SavingDebugFile', f'Saving debug file to "{path.absolute()}" ...') 280 | with open(path, 'w+', encoding='utf-8') as f: 281 | f.write(content) 282 | f.close() 283 | return path 284 | 285 | 286 | def get_console_message(message: str): 287 | message = message + '&!r' 288 | for color_code, color in COLORS: 289 | message = message.replace(color_code, color) 290 | return message 291 | 292 | 293 | log_error = lambda src, msg: logging.error(get_console_message(f'&c({src})&r ' + msg + '&!r')) 294 | log_info = lambda src, msg: logging.info(get_console_message(f'&c({src})&y ' + msg + '&!r')) 295 | log_debug = lambda src, msg: logging.debug(get_console_message(f'&c({src})&y ' + msg + '&!r')) 296 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 2.0.6 --------------------------------------------------------------------------------