Repository: Instinctlol/automatic-twitch-recorder Branch: master Commit: dca1c582702f Files: 10 Total size: 29.8 KB Directory structure: gitextract_6mjceabh/ ├── .gitignore ├── ATRHandler.py ├── README.md ├── atr_cmd.py ├── daemon.py ├── main.py ├── requirements.txt ├── twitch.py ├── utils.py └── watcher.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ Pipfile* */* client_id* client_secret* ngrok_auth_token* config.txt ================================================ FILE: ATRHandler.py ================================================ from http import HTTPStatus from http.server import BaseHTTPRequestHandler import logging from urllib.parse import urlparse import hmac import json from jsonschema import validate, ValidationError from daemon import Daemon class ATRHandler(BaseHTTPRequestHandler): schema_cmd = { 'type': 'object', 'properties': { 'cmd': {'type': 'string'}, 'args': {'type': 'array', 'items': {'type': 'string'}}, }, } message = {} ok = False # comment this out when developing :) def log_message(self, format, *args): return def _set_response(self, msg=None): self.send_response(HTTPStatus.OK, msg) self.send_header('Content-type', 'text/html') self.end_headers() def _set_bad_request(self, msg): self.send_response(HTTPStatus.BAD_REQUEST, msg) self.send_header('Content-type', 'text/html') self.end_headers() self.wfile.write(msg.encode('utf-8')) def _send_json_response(self): self.send_response(HTTPStatus.OK) self.send_header('Content-type', 'application/json') self.end_headers() dump = json.dumps(self.message) self.log_message('body: ' + dump) self.wfile.write(dump.encode(encoding='utf_8')) def _send_bad_json_response(self): self.send_response(HTTPStatus.BAD_REQUEST) self.send_header('Content-type', 'application/json') self.end_headers() dump = json.dumps(self.message) self.log_message('body: ' + dump) self.wfile.write(dump.encode(encoding='utf_8')) def do_GET(self): """Handles GET requests, will send challenge back to twitch to register webhook. """ query = urlparse(self.path).query logging.info('GET request,\nPath: %s\nHeaders:\n%s\n', str(self.path), str(self.headers)) try: query_components = dict(qc.split('=') for qc in query.split('&')) challenge = query_components['hub.challenge'] # s = ''.join(x for x in challenge if x.isdigit()) # print(s) # print(challenge) self.send_response(HTTPStatus.OK) self.end_headers() self.wfile.write(bytes(challenge, 'utf-8')) except: query_components = None challenge = None self._set_response() self.wfile.write(bytes('Hello Stranger :)', 'utf-8')) def do_POST(self): content_length = int(self.headers['Content-Length']) # <--- Gets the size of data post_data = self.rfile.read(content_length).decode() # <--- Gets the data itself logging.info('POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n', str(self.path), str(self.headers), post_data) if self.path == '/cmd/': payload = json.loads(post_data) try: validate(instance=payload, schema=self.schema_cmd) self.handle_cmd(payload) if self.ok: self._send_json_response() else: self._send_bad_json_response() # self._set_response() # self.wfile.write("POST request for {}".format(self.path).encode('utf-8')) except ValidationError as validationerror: self.message['println'] = 'Could not validate request payload for cmd:\n' + str(validationerror) self._send_bad_json_response() else: if 'Content-Type' in self.headers: content_type = str(self.headers['Content-Type']) else: raise ValueError('not all headers supplied.') if 'X-Hub-Signature' in self.headers: hub_signature = str(self.headers['X-Hub-Signature']) algorithm, hashval = hub_signature.split('=') print(hashval) print(algorithm) if post_data and algorithm and hashval: gg = hmac.new(Daemon.WEBHOOK_SECRET.encode(), post_data, algorithm) if not hmac.compare_digest(hashval.encode(), gg.hexdigest().encode()): raise ConnectionError('Hash missmatch.') else: raise ValueError('not all headers supplied.') self._set_response() self.wfile.write('POST request for {}'.format(self.path).encode('utf-8')) def handle_cmd(self, post_data): """Handles POST requests on /cmd/. These contain a single command with optional arguments supplied by the user via GUI or CLI. Parameters: ---------- post_data (dict): Contains the keys `cmd` and `args`.\n post_data['cmd'] (str): the command to execute. \n post_data['args'] (list): the arguments for the command. """ cmd_executor = { 'exit': self.cmd_exit, 'start': self.cmd_start, 'list': self.cmd_list, 'remove': self.cmd_remove, 'add': self.cmd_add, 'time': self.cmd_time, 'download_folder': self.cmd_download_folder, } func = cmd_executor[post_data['cmd']] if len(post_data['args']) > 0: func(post_data['args']) else: func() def cmd_exit(self): self.message['println'] = self.server.exit() self.ok = True def cmd_start(self): self.message['println'] = self.server.start() self.ok = True def cmd_remove(self, args): try: self.ok, self.message['println'] = self.server.remove_streamer(args[0]) except IndexError: self.ok = False self.message['println'] = 'Missing streamer in arguments.' def cmd_list(self): live, offline = self.server.get_streamers() msg = 'Live: ' + str(live).strip('[]') + '\n' + \ 'Offline: ' + str(offline).strip('[]') self.message['println'] = msg self.ok = True def cmd_add(self, args): if len(args) == 0: self.message['println'] = 'Missing streamer in arguments.' self._send_bad_json_response() return if len(args) > 1: self.ok, resp = self.server.add_streamer(args[0], args[1]) else: self.ok, resp = self.server.add_streamer(args[0]) self.message['println'] = '\n'.join(resp) def cmd_time(self, args): try: self.message['println'] = self.server.set_interval(int(args[0])) self.ok = True except ValueError: self.ok = False self.message['println'] = '\'' + args[0] + '\' is not valid.' def cmd_download_folder(self, args): try: self.message['println'] = self.server.set_download_folder(str(args[0]).strip()) self.ok = True except ValueError: self.ok = False self.message['println'] = '\'' + args[0] + '\' is not valid.' ================================================ FILE: README.md ================================================ # discontinued This script was declared end-of-life and is not maintained anymore. Please have a look into well maintained, more feature-rich solutions, like [ganymede](https://github.com/Zibbp/ganymede) and [LiveStreamDVR](https://github.com/MrBrax/LiveStreamDVR) # automatic-twitch-recorder Checks if a user on twitch is currently streaming and then records the stream via streamlink ## Dependencies: - streamlink (https://streamlink.github.io) - python3 (https://www.python.org/) (I use [python3.6](https://www.python.org/downloads/release/python-368/) for windows) ## Installation: - clone this repo or download - make sure you have python3 installed - open cmd/terminal - change directory into folder containing the file 'requirements.txt' - type `pip install -r requirements.txt` ## Usage: ### Using the CLI (Command-line interface) - in your cmd/terminal, run `python main.py` - type `help` - `add streamer [quality]`: adds the streamer you want to record in given optional quality, e.g. `add forsen`. Default quality: `best`, quality options: `1080p60, 1080p, 720p60, 720p, 480p, 360p, 160p, audio_only` - `time 10`: sets check interval in seconds - `remove streamer`: removes streamer, also stops recording this streamer - `start`: starts checking for / recording all added streamers - `list`: prints all added streamers - `exit`: stops the application and all currently running recordings - `download_folder path`: sets the download folder for saving the recordings. (#streamer# will be replaced with the name of the streamer) Example inputs to record forsen and nymn (this will also repeatedly check if they are online): ``` $ add forsen $ add nymn $ start ``` ## Bugs: - CLI shenanigans - text will get printed into the prompt / user input. However, your input will still be valid, so do not worry. This does not restrict the functionality of this application. - There's an open [stackoverflow question](https://stackoverflow.com/questions/57027294/cmd-module-async-job-prints-are-overwriting-prompt-input) for this. Any volunteers? ## Plans for the future: - Refactor to easily support any supported streamlink platform, e.g. YouTube. - When done recording, upload to YouTube - Export to .exe so you don't have to install python - PyInstaller and streamlink apparently do not work well together (streamlink will throw NoPluginError). Help is appreciated. - Create a GUI with Qt (PyQt5 or PySide2) (fairly easy, but time consuming) ================================================ FILE: atr_cmd.py ================================================ import cmd import sys import requests # TODO: https://stackoverflow.com/questions/37866403/python-cmd-module-resume-prompt-after-async-event class AtrCmd(cmd.Cmd): def _send_cmd(self, cmd_payload): r = requests.post('http://127.0.0.1:1234/cmd/', json=cmd_payload) resp_json = r.json() resp_ok = r.ok print(resp_json.pop('println')) return resp_ok, resp_json def _create_payload(self, command, *args): payload = {'cmd': command, 'args': list(args) } return payload def __init__(self): super().__init__() def do_add(self, line): line = line.split(' ') payload = self._create_payload('add', *line) self._send_cmd(payload) def help_add(self): print('\n'.join([ 'add streamer [quality]', 'Adds streamer to watchlist with (optional) selected quality.', 'Default quality: best', ])) def do_remove(self, line): payload = self._create_payload('remove', line) self._send_cmd(payload) def help_remove(self): print('\n'.join([ 'remove streamer', 'Removes streamer from watchlist, also stops recording if currently recording streamer.', ])) def do_list(self, line): payload = self._create_payload('list') self._send_cmd(payload) def help_list(self): print('\n'.join([ 'list', 'List all watched streamers, seperated in offline and live sets.', ])) def do_start(self, line): payload = self._create_payload('start') self._send_cmd(payload) def help_start(self): print('\n'.join([ 'start', 'Starts the configured daemon. You may still configure it further while it is running.', ])) def do_time(self, line): try: payload = self._create_payload('time', line) self._send_cmd(payload) except ValueError: print('\''+line+'\' is not valid.') def help_time(self): print('\n'.join([ 'time seconds', 'Configures the check interval in seconds.', 'It\'s advised not to make it too low and to stay above 10 seconds.', 'Default check interval: 30 seconds.', ])) def do_download_folder(self, line): payload = self._create_payload('download_folder', line) self._send_cmd(payload) def help_download_folder(self): print('\n'.join([ 'download_folder path', 'Configures the download folder for saving the videos.', ])) def do_EOF(self, line): self.do_exit(line) return True def do_exit(self, line): payload = self._create_payload('exit') self._send_cmd(payload) sys.exit() def help_exit(self): print('\n'.join([ 'exit', 'Exits the application, stopping all running recording tasks.', ])) def cmdloop_with_keyboard_interrupt(self): try: self.cmdloop() except KeyboardInterrupt: self.do_exit('') if __name__ == '__main__': AtrCmd().cmdloop_with_keyboard_interrupt() ================================================ FILE: daemon.py ================================================ import logging import os import threading from concurrent.futures import ThreadPoolExecutor from http.server import HTTPServer import ATRHandler import twitch from utils import get_client_id, StreamQualities from watcher import Watcher class Daemon(HTTPServer): # # CONSTANTS # VALID_BROADCAST = ['live'] # 'rerun' can be added through commandline flags/options WEBHOOK_SECRET = 'automaticTwitchRecorder' WEBHOOK_URL_PREFIX = 'https://api.twitch.tv/helix/streams?user_id=' LEASE_SECONDS = 864000 # 10 days = 864000 check_interval = 10 def __init__(self, server_address, RequestHandlerClass): super().__init__(server_address, RequestHandlerClass) self.PORT = server_address[1] self.streamers = {} # holds all streamers that need to be surveilled self.watched_streamers = {} # holds all live streamers that are currently being recorded self.client_id = get_client_id() self.kill = False self.started = False self.download_folder = os.getcwd() + os.path.sep + "#streamer#" # ThreadPoolExecutor(max_workers): If max_workers is None or not given, it will default to the number of # processors on the machine, multiplied by 5 self.pool = ThreadPoolExecutor() def add_streamer(self, streamer, quality=StreamQualities.BEST.value): streamer = streamer.lower() streamer_dict = {} resp = [] ok = False qualities = [q.value for q in StreamQualities] if quality not in qualities: resp.append('Invalid quality: ' + quality + '.') resp.append('Quality options: ' + str(qualities)) else: streamer_dict.update({'preferred_quality': quality}) # get channel id of streamer user_info = list(twitch.get_user_info(streamer)) # check if user exists if user_info: streamer_dict.update({'user_info': user_info[0]}) self.streamers.update({streamer: streamer_dict}) resp.append('Successfully added ' + streamer + ' to watchlist.') ok = True else: resp.append('Invalid streamer name: ' + streamer + '.') return ok, resp def remove_streamer(self, streamer): streamer = streamer.lower() if streamer in self.streamers.keys(): self.streamers.pop(streamer) return True, 'Removed ' + streamer + ' from watchlist.' elif streamer in self.watched_streamers.keys(): watcher = self.watched_streamers[streamer]['watcher'] watcher.quit() return True, 'Removed ' + streamer + ' from watchlist.' else: return False, 'Could not find ' + streamer + '. Already removed?' def start(self): if not self.started: self._check_streams() self.started = True return 'Daemon is started.' else: return 'Daemon is already running.' def set_interval(self, secs): if secs < 1: secs = 1 self.check_interval = secs return 'Interval is now set to ' + str(secs) + ' seconds.' def set_download_folder(self, download_folder): self.download_folder = download_folder return 'Download folder is now set to \'' + download_folder + '\' .' def _check_streams(self): user_ids = [] # get channel ids of all streamers for streamer in self.streamers.keys(): user_ids.append(self.streamers[streamer]['user_info']['id']) if user_ids: streams_info = twitch.get_stream_info(*user_ids) # save streaming information for all streamers, if it exists for stream_info in streams_info: streamer_name = stream_info['user_name'].lower() self.streamers[streamer_name].update({'stream_info': stream_info}) live_streamers = [] # check which streamers are live for streamer_info in self.streamers.values(): try: stream_info = streamer_info['stream_info'] if stream_info['type'] == 'live': live_streamers.append(stream_info['user_name'].lower()) except KeyError: pass self._start_watchers(live_streamers) if not self.kill: t = threading.Timer(self.check_interval, self._check_streams) t.start() def _start_watchers(self, live_streamers_list): for live_streamer in live_streamers_list: if live_streamer not in self.watched_streamers: live_streamer_dict = self.streamers.pop(live_streamer) curr_watcher = Watcher(live_streamer_dict, self.download_folder) self.watched_streamers.update({live_streamer: {'watcher': curr_watcher, 'streamer_dict': live_streamer_dict}}) if not self.kill: t = self.pool.submit(curr_watcher.watch) t.add_done_callback(self._watcher_callback) def _watcher_callback(self, returned_watcher): streamer_dict = returned_watcher.result() streamer = streamer_dict['user_info']['login'] kill = streamer_dict['kill'] cleanup = streamer_dict['cleanup'] self.watched_streamers.pop(streamer) if not cleanup: print('Finished watching ' + streamer) else: output_filepath = streamer_dict['output_filepath'] if os.path.exists(output_filepath): os.remove(output_filepath) if not kill: self.add_streamer(streamer, streamer_dict['preferred_quality']) def get_streamers(self): return list(self.watched_streamers.keys()), list(self.streamers.keys()) def exit(self): self.kill = True for streamer in self.watched_streamers.values(): watcher = streamer['watcher'] watcher.quit() self.pool.shutdown() self.server_close() threading.Thread(target=self.shutdown, daemon=True).start() return 'Daemon exited successfully' if __name__ == '__main__': logging.basicConfig(level=logging.INFO) server = Daemon(('127.0.0.1', 1234), ATRHandler.ATRHandler) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.exit() print('exited gracefully') ================================================ FILE: main.py ================================================ import threading import ATRHandler import utils from atr_cmd import AtrCmd from daemon import Daemon if __name__ == '__main__': utils.get_client_id() # creates necessary config before launch server = Daemon(('127.0.0.1', 1234), ATRHandler.ATRHandler) threading.Thread(target=server.serve_forever).start() AtrCmd().cmdloop_with_keyboard_interrupt() ================================================ FILE: requirements.txt ================================================ attrs==20.3.0 certifi==2020.12.5 chardet==4.0.0 idna==2.10 importlib-metadata==3.3.0 iso-639==0.4.5 iso3166==1.0.1 isodate==0.6.0 jsonschema==3.2.0 pathvalidate==2.3.1 pycryptodome==3.9.9 pyrsistent==0.17.3 PySocks==1.7.1 requests==2.25.1 six==1.15.0 streamlink==2.0.0 typing-extensions==3.7.4.3 urllib3==1.26.2 websocket-client==0.57.0 zipp==3.4.0 ================================================ FILE: twitch.py ================================================ import requests import utils auth = {'Client-ID': str(utils.get_client_id()), 'Authorization': 'Bearer ' + utils.get_app_access_token()} def get_user_info(user_login, *args: str) -> list: """ Gets user info for user logins See https://dev.twitch.tv/docs/api/reference#get-users Parameters ---------- user_login: str username string args: str additional string usernames (max. 99) Returns ------- list contains user_info dicts """ get_user_id_url = 'https://api.twitch.tv/helix/users?login=' + user_login if len(args) > 99: args = args[:99] for user_login_i in args: get_user_id_url += '&login=' + user_login_i r = requests.get(get_user_id_url, headers=auth) temp = r.json() if temp['data']: return list(temp['data']) else: return [] def get_stream_info(user_id: str, *args): """ Gets stream info for user ids See https://dev.twitch.tv/docs/api/reference#get-streams Parameters ---------- user_id: str user id string args: str additional string user ids (max. 99) Returns ------- list contains stream_info dicts """ if len(args) > 99: args = args[:99] get_user_id_url = 'https://api.twitch.tv/helix/streams?first=100&user_id=' + user_id for user_id in args: get_user_id_url += '&user_id=' + user_id r = requests.get(get_user_id_url, headers=auth) temp = r.json() if temp['data']: return list(temp['data']) else: return [] ================================================ FILE: utils.py ================================================ import os from datetime import datetime, timedelta from enum import Enum from pathlib import Path import json import requests from pathvalidate import sanitize_filename CONFIG_FILE = os.getcwd() + os.path.sep + 'config.txt' # Location of config.txt config file. _APP_ACCESS_TOKEN = '' _APP_ACCESS_TOKEN_REFRESH_TIME = None CONFIG = None # TODO: figure it out from streamlink library class StreamQualities(Enum): AUDIO_ONLY = 'audio_only' _160p = '160p' _360 = '360p' _480p = '480p' _720p = '720p' _720p60 = '720p60' _1080p = '1080p' _1080p60 = '1080p60' WORST = 'worst' BEST = 'best' def _read_config(): global CONFIG config_path = Path(CONFIG_FILE) try: config_path.resolve(strict=True) except FileNotFoundError as ex: config_file = open(config_path, 'w') CONFIG = { 'client_id': '', 'client_secret': '', 'ngrok_authtoken': '' } config_file.write(json.dumps(CONFIG)) config_file.close() else: config_file = open(config_path, 'r') CONFIG = json.loads(config_file.read()) config_file.close() def _write_config(): global CONFIG config_path = Path(CONFIG_FILE) config_file = open(config_path, 'w') config_file.write(json.dumps(CONFIG)) config_file.close() def get_client_id(): global CONFIG if not CONFIG: _read_config() if not CONFIG['client_id']: print('Client id unset.') print('Visit the following website to generate a client id and client secret for this script.') print('https://dev.twitch.tv/console/apps') print('Enter client id from website.') CONFIG['client_id'] = input('client id: ') print('Enter client secret from website.') CONFIG['client_secret'] = input('client secret: ') _write_config() return CONFIG['client_id'] def get_client_secret(): global CONFIG if not CONFIG: _read_config() if not CONFIG['client_secret']: print('Client secret unset.') print('Visit the following website to get a client secret for this script.') print('https://dev.twitch.tv/console/apps') print('Enter client secret from website.') CONFIG['client_secret'] = input('client secret: ') _write_config() return CONFIG['client_secret'] def get_ngrok_auth_token(): global CONFIG if not CONFIG: _read_config() if not CONFIG['ngrok_authtoken']: print('Ngrok authtoken unset.') print('Visit the following website to generate an authtoken for this script.') print('https://dashboard.ngrok.com/auth/your-authtoken') print('Enter authtoken from website.') CONFIG['ngrok_authtoken'] = input('Authtoken: ') _write_config() return CONFIG['ngrok_authtoken'] def get_app_access_token(): global _APP_ACCESS_TOKEN, _APP_ACCESS_TOKEN_REFRESH_TIME # API Notes: # App access tokens and ID tokens cannot be refreshed. # No scopes are needed when requesting app access tokens. oauth_url = 'https://id.twitch.tv/oauth2/token?client_id={0}&client_secret={1}&grant_type=client_credentials' if not _APP_ACCESS_TOKEN or not _APP_ACCESS_TOKEN_REFRESH_TIME or _APP_ACCESS_TOKEN_REFRESH_TIME < datetime.now(): r = requests.post(oauth_url.format(get_client_id(), get_client_secret())) # { # "access_token": "", # "refresh_token": "", # "expires_in": , # "scope": [""], # "token_type": "bearer" # } oauth_json = r.json() _APP_ACCESS_TOKEN = oauth_json['access_token'] _APP_ACCESS_TOKEN_REFRESH_TIME = datetime.now() + timedelta(seconds=oauth_json['expires_in'] - 60) return _APP_ACCESS_TOKEN def get_valid_filename(s): s = str(s) return sanitize_filename(s) ================================================ FILE: watcher.py ================================================ import datetime import streamlink import os from utils import get_valid_filename, StreamQualities class Watcher: streamer_dict = {} streamer = '' stream_title = '' stream_quality = '' kill = False cleanup = False def __init__(self, streamer_dict, download_folder): self.streamer_dict = streamer_dict self.streamer = self.streamer_dict['user_info']['display_name'] self.streamer_login = self.streamer_dict['user_info']['login'] self.stream_title = self.streamer_dict['stream_info']['title'] self.stream_quality = self.streamer_dict['preferred_quality'] self.download_folder = download_folder def quit(self): self.kill = True def clean_break(self): self.cleanup = True def watch(self): curr_time = datetime.datetime.now().strftime("%Y-%m-%d %H.%M.%S") file_name = curr_time + " - " + self.streamer + " - " + get_valid_filename(self.stream_title) + ".ts" directory = self._formatted_download_folder(self.streamer_login) + os.path.sep if not os.path.exists(directory): os.makedirs(directory) output_filepath = directory + file_name self.streamer_dict.update({'output_filepath': output_filepath}) streams = streamlink.streams('https://www.twitch.tv/' + self.streamer_login) # Occurs when already recording another stream and new streamer (that is already live) is added # not sure why this error is thrown.. # Traceback (most recent call last): # File "C:\Program Files\Python36\lib\threading.py", line 916, in _bootstrap_inner # self.run() # File "E:\Downloads\automatic-twitch-recorder\venv\lib\site-packages\streamlink\stream\segmented.py", line 59, in run # for segment in self.iter_segments(): # File "E:\Downloads\automatic-twitch-recorder\venv\lib\site-packages\streamlink\stream\hls.py", line 307, in iter_segments # self.reload_playlist() # File "E:\Downloads\automatic-twitch-recorder\venv\lib\site-packages\streamlink\stream\hls.py", line 235, in reload_playlist # self.process_sequences(playlist, sequences) # File "E:\Downloads\automatic-twitch-recorder\venv\lib\site-packages\streamlink\plugins\twitch.py", line 210, in process_sequences # return super(TwitchHLSStreamWorker, self).process_sequences(playlist, sequences) # TypeError: super(type, obj): obj must be an instance or subtype of type try: stream = streams[self.stream_quality] except KeyError: temp_quality = self.stream_quality if len(streams) > 0: # False => stream is probably offline if self.stream_quality in streams.keys(): self.stream_quality = StreamQualities.BEST.value else: self.stream_quality = list(streams.keys())[-1] # best not in streams? choose best effort quality else: self.cleanup = True if not self.cleanup: print('Invalid stream quality: ' + '\'' + temp_quality + '\'') print('Falling back to default case: ' + self.stream_quality) self.streamer_dict['preferred_quality'] = self.stream_quality stream = streams[self.stream_quality] else: stream = None if not self.kill and not self.cleanup and stream: print(self.streamer + ' is live. Saving stream in ' + self.stream_quality + ' quality to ' + output_filepath + '.') try: with open(output_filepath, "ab") as out_file: # open for [a]ppending as [b]inary fd = stream.open() while not self.kill and not self.cleanup: data = fd.read(1024) # If data is empty the stream has ended if not data: fd.close() out_file.close() break out_file.write(data) except streamlink.StreamError as err: print('StreamError: {0}'.format(err)) # TODO: test when this happens except IOError as err: # If file validation fails this error gets triggered. print('Failed to write data to file: {0}'.format(err)) self.streamer_dict.update({'kill': self.kill}) self.streamer_dict.update({'cleanup': self.cleanup}) return self.streamer_dict def _formatted_download_folder(self, streamer): return self.download_folder.replace('#streamer#', streamer)