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