master dca1c582702f cached
10 files
29.8 KB
7.1k tokens
64 symbols
1 requests
Download .txt
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": "<user access token>",
        #   "refresh_token": "",
        #   "expires_in": <number of seconds until the token expires>,
        #   "scope": ["<your previously listed scope(s)>"],
        #   "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)
Download .txt
gitextract_6mjceabh/

├── .gitignore
├── ATRHandler.py
├── README.md
├── atr_cmd.py
├── daemon.py
├── main.py
├── requirements.txt
├── twitch.py
├── utils.py
└── watcher.py
Download .txt
SYMBOL INDEX (64 symbols across 6 files)

FILE: ATRHandler.py
  class ATRHandler (line 12) | class ATRHandler(BaseHTTPRequestHandler):
    method log_message (line 24) | def log_message(self, format, *args):
    method _set_response (line 27) | def _set_response(self, msg=None):
    method _set_bad_request (line 32) | def _set_bad_request(self, msg):
    method _send_json_response (line 38) | def _send_json_response(self):
    method _send_bad_json_response (line 46) | def _send_bad_json_response(self):
    method do_GET (line 54) | def do_GET(self):
    method do_POST (line 75) | def do_POST(self):
    method handle_cmd (line 114) | def handle_cmd(self, post_data):
    method cmd_exit (line 139) | def cmd_exit(self):
    method cmd_start (line 143) | def cmd_start(self):
    method cmd_remove (line 147) | def cmd_remove(self, args):
    method cmd_list (line 154) | def cmd_list(self):
    method cmd_add (line 161) | def cmd_add(self, args):
    method cmd_time (line 174) | def cmd_time(self, args):
    method cmd_download_folder (line 182) | def cmd_download_folder(self, args):

FILE: atr_cmd.py
  class AtrCmd (line 8) | class AtrCmd(cmd.Cmd):
    method _send_cmd (line 10) | def _send_cmd(self, cmd_payload):
    method _create_payload (line 17) | def _create_payload(self, command, *args):
    method __init__ (line 23) | def __init__(self):
    method do_add (line 26) | def do_add(self, line):
    method help_add (line 31) | def help_add(self):
    method do_remove (line 38) | def do_remove(self, line):
    method help_remove (line 42) | def help_remove(self):
    method do_list (line 48) | def do_list(self, line):
    method help_list (line 52) | def help_list(self):
    method do_start (line 58) | def do_start(self, line):
    method help_start (line 62) | def help_start(self):
    method do_time (line 68) | def do_time(self, line):
    method help_time (line 75) | def help_time(self):
    method do_download_folder (line 83) | def do_download_folder(self, line):
    method help_download_folder (line 87) | def help_download_folder(self):
    method do_EOF (line 93) | def do_EOF(self, line):
    method do_exit (line 97) | def do_exit(self, line):
    method help_exit (line 102) | def help_exit(self):
    method cmdloop_with_keyboard_interrupt (line 108) | def cmdloop_with_keyboard_interrupt(self):

FILE: daemon.py
  class Daemon (line 13) | class Daemon(HTTPServer):
    method __init__ (line 23) | def __init__(self, server_address, RequestHandlerClass):
    method add_streamer (line 36) | def add_streamer(self, streamer, quality=StreamQualities.BEST.value):
    method remove_streamer (line 61) | def remove_streamer(self, streamer):
    method start (line 73) | def start(self):
    method set_interval (line 81) | def set_interval(self, secs):
    method set_download_folder (line 87) | def set_download_folder(self, download_folder):
    method _check_streams (line 91) | def _check_streams(self):
    method _start_watchers (line 123) | def _start_watchers(self, live_streamers_list):
    method _watcher_callback (line 134) | def _watcher_callback(self, returned_watcher):
    method get_streamers (line 149) | def get_streamers(self):
    method exit (line 152) | def exit(self):

FILE: twitch.py
  function get_user_info (line 9) | def get_user_info(user_login, *args: str) -> list:
  function get_stream_info (line 39) | def get_stream_info(user_id: str, *args):

FILE: utils.py
  class StreamQualities (line 17) | class StreamQualities(Enum):
  function _read_config (line 30) | def _read_config():
  function _write_config (line 50) | def _write_config():
  function get_client_id (line 58) | def get_client_id():
  function get_client_secret (line 74) | def get_client_secret():
  function get_ngrok_auth_token (line 88) | def get_ngrok_auth_token():
  function get_app_access_token (line 102) | def get_app_access_token():
  function get_valid_filename (line 125) | def get_valid_filename(s):

FILE: watcher.py
  class Watcher (line 7) | class Watcher:
    method __init__ (line 15) | def __init__(self, streamer_dict, download_folder):
    method quit (line 23) | def quit(self):
    method clean_break (line 26) | def clean_break(self):
    method watch (line 29) | def watch(self):
    method _formatted_download_folder (line 100) | def _formatted_download_folder(self, streamer):
Condensed preview — 10 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (32K chars).
[
  {
    "path": ".gitignore",
    "chars": 67,
    "preview": "Pipfile*\n*/*\nclient_id*\nclient_secret*\nngrok_auth_token*\nconfig.txt"
  },
  {
    "path": "ATRHandler.py",
    "chars": 7051,
    "preview": "from http import HTTPStatus\nfrom http.server import BaseHTTPRequestHandler\nimport logging\nfrom urllib.parse import urlpa"
  },
  {
    "path": "README.md",
    "chars": 2489,
    "preview": "# discontinued\n\nThis script was declared end-of-life and is not maintained anymore. Please have a look into well maintai"
  },
  {
    "path": "atr_cmd.py",
    "chars": 3271,
    "preview": "import cmd\nimport sys\n\nimport requests\n\n\n# TODO: https://stackoverflow.com/questions/37866403/python-cmd-module-resume-p"
  },
  {
    "path": "daemon.py",
    "chars": 6565,
    "preview": "import logging\nimport os\nimport threading\nfrom concurrent.futures import ThreadPoolExecutor\nfrom http.server import HTTP"
  },
  {
    "path": "main.py",
    "chars": 367,
    "preview": "import threading\n\nimport ATRHandler\nimport utils\nfrom atr_cmd import AtrCmd\nfrom daemon import Daemon\n\nif __name__ == '_"
  },
  {
    "path": "requirements.txt",
    "chars": 349,
    "preview": "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\nisoda"
  },
  {
    "path": "twitch.py",
    "chars": 1595,
    "preview": "import requests\n\nimport utils\n\nauth = {'Client-ID': str(utils.get_client_id()),\n        'Authorization': 'Bearer ' + uti"
  },
  {
    "path": "utils.py",
    "chars": 3987,
    "preview": "import os\nfrom datetime import datetime, timedelta\nfrom enum import Enum\nfrom pathlib import Path\nimport json\n\nimport re"
  },
  {
    "path": "watcher.py",
    "chars": 4753,
    "preview": "import datetime\nimport streamlink\nimport os\nfrom utils import get_valid_filename, StreamQualities\n\n\nclass Watcher:\n    s"
  }
]

About this extraction

This page contains the full source code of the Instinctlol/automatic-twitch-recorder GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 10 files (29.8 KB), approximately 7.1k tokens, and a symbol index with 64 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!