├── .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 | [](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: 
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
--------------------------------------------------------------------------------