Repository: xenova/chat-downloader (language: python)

Issue:
[BUG] chat.start_time is null
Hi,

<!--
Please follow the template as closely as possible. You can remove sections which do not apply to you (e.g. code used, screenshots)
-->

## Basic information
- Program version: 0.0.6
- Python version: 3.8.0
- Operating system: Windows 10

## Describe the bug
ChatDownloader().get_chat(url) returns 'null' for start_time

## Command/Code used
```
from chat_downloader import ChatDownloader

url = 'https://www.youtube.com/watch?v=9oX8-4dIkpss'
chat = ChatDownloader().get_chat(url)       # create a generator
print(chat.start_time)
```
### Otherwise, if using the python module, provide the following:

1. A [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example):
It's the same code as above

2. Output, traceback or other information relating to the bug:
```
{'chat': <generator object YouTubeChatDownloader._get_chat_messages at 0x0000018E349784A0>, 'title': "【Visage】最終回！もう怖い！Visage正式リリース！I'm so scared!#3【周防パトラ / ハニスト】", 'duration': 12288, 'is_live': False, 'start_time': None, 'site': <chat_downloader.sites.youtube.YouTubeChatDownloader object at 0x0000018E36F52550>, 'format': <function ChatDownloader.get_chat.<locals>.<lambda> at 0x0000018E36F5B310>}
```

## Expected behavior
Should not be null
Flush stdout after every message
Stdout will block-buffer if it is redirected, which affects capturing/saving stdout with a program like tee. This makes it so messages are printed as they are received, even if stdout isn't a tty.
[QUESTION] KeyError: 'isCustomEmoji'
I tried to download chat from archives with the code below.
Is there a way around this? Or can I ignore it?

```python
li_fetched_data = []

for video_id in tqdm(li_video_ids):

    url = f'https://www.youtube.com/watch?v={video_id}'
    chat = ChatDownloader().get_chat(
        url,
        start_time=None,
        end_time=None,
        max_attempts=15,
        retry_timeout=None,
        timeout=None,
        max_messages=None,
        logging='info',
        pause_on_debug=False,
        exit_on_debug=False,
        inactivity_timeout=None,
        message_types=None,
        format_file=None,
        chat_type='live',
        message_receive_timeout=0.1,
        buffer_size=4096,
    )
    
    chats = []
    for message in chat:
        chats.append(message)
        
    li_fetched_data.append(chats)
```


```python
 22%|█████████████████                                                            | 21/95 [1:37:07<5:42:15, 277.51s/it]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<timed exec> in <module>

d:\python\syds\sydsenv\lib\site-packages\chat_downloader\sites\common.py in __iter__(self)
     58 
     59     def __iter__(self):
---> 60         yield from self.chat
     61 
     62     # Must be set later

d:\python\syds\sydsenv\lib\site-packages\chat_downloader\sites\youtube.py in _get_chat_messages(self, initial_info, params)
   1139                         original_message_type = try_get_first_key(
   1140                             original_item)
-> 1141                         data = self._parse_item(original_item, data)
   1142 
   1143                     elif original_action_type in self._KNOWN_REMOVE_ACTION_TYPES:

d:\python\syds\sydsenv\lib\site-packages\chat_downloader\sites\youtube.py in _parse_item(item, info)
    411         for key in item_info:
    412             BaseChatDownloader.remap(
--> 413                 info, YouTubeChatDownloader._REMAPPING, key, item_info[key])
    414 
    415         # check for colour information

d:\python\syds\sydsenv\lib\site-packages\chat_downloader\sites\common.py in remap(info, remapping_dict, remap_key, remap_input, keep_unknown_keys, replace_char_with_underscores)
    278                 # Perform transformation
    279                 if remap.remap_function:  # Has a remap function
--> 280                     new_value = remap.remap_function(remap_input)
    281                 else:  # No remap function specified, apply identity transformation
    282                     new_value = remap_input

d:\python\syds\sydsenv\lib\site-packages\chat_downloader\sites\youtube.py in parse_runs(run_info, parse_links)
    384                         'search_terms': emoji['searchTerms'],
    385                         'images': YouTubeChatDownloader.parse_thumbnails(emoji['image']),
--> 386                         'is_custom_emoji': emoji['isCustomEmoji']
    387                     }
    388 

KeyError: 'isCustomEmoji'
```

contents of emoji by debug function
```
ipdb>  emoji
{'emojiId': '❤', 'shortcuts': [':red_heart:', ':heart:', '<3'], 'searchTerms': ['red', 'heart'], 'image': {'thumbnails': [{'url': 'https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u2764.svg'}]}}
```


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 6d295f2..bfb8e70 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.5, 3.6, 3.7, 3.8, 3.9]
+        python-version: [3.6, 3.7, 3.8, 3.9]
 
     steps:
     - uses: actions/checkout@v2
diff --git a/HISTORY.md b/HISTORY.rst
similarity index 100%
rename from HISTORY.md
rename to HISTORY.rst
diff --git a/Makefile b/Makefile
index 1e6b5ef..bffd4b1 100644
--- a/Makefile
+++ b/Makefile
@@ -63,13 +63,11 @@ coverage: ## check code coverage quickly with the default Python
 	coverage html
 	$(BROWSER) htmlcov/index.html
 
-docs: ## generate Sphinx HTML documentation, including API docs
-	rm -f docs/chat_downloader.rst
-	rm -f docs/modules.rst
-	sphinx-apidoc -o docs/ chat_downloader
+docs: ## generate Sphinx HTML documentation and README
 	$(MAKE) -C docs clean
 	$(MAKE) -C docs html
-	$(BROWSER) docs/_build/html/index.html
+	$(BROWSER) docs/_build/index.html
+	cd docs && python generate_readme.py > ../README.rst
 
 servedocs: docs ## compile the docs watching for changes
 	watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D .
diff --git a/README.md b/README.md
deleted file mode 100644
index 2a0fa77..0000000
--- a/README.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# Chat Downloader
-A simple tool used to retrieve chat messages from livestreams, videos, clips and past broadcasts. No authentication needed!
-
-[![Python](https://img.shields.io/pypi/pyversions/chat-downloader)](https://pypi.org/project/chat-downloader)
-[![PyPI version](https://img.shields.io/pypi/v/chat-downloader.svg)](https://pypi.org/project/chat-downloader)
-[![Downloads](https://pepy.tech/badge/chat-downloader)](https://pypi.org/project/chat-downloader)
-[![GitHub license](https://img.shields.io/github/license/xenova/chat-downloader)](https://github.com/xenova/chat-downloader/blob/master/LICENSE)
-
-
-<!-- [![PyPI Downloads](https://img.shields.io/pypi/dm/chat-downloader)](https://pypi.org/project/chat-downloader) -->
-<!---
-[![GitHub issues](https://img.shields.io/github/issues/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
-[![GitHub forks](https://img.shields.io/github/forks/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
-[![GitHub stars](https://img.shields.io/github/stars/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
-[![Downloads](https://img.shields.io/github/downloads/xenova/chat-downloader/total.svg)](https://github.com/xenova/chat-downloader/releases)
--->
-
-## Installation
-### Install using `pip`
-```
-pip install chat-downloader
-```
-
-To update to the latest version, add the `--upgrade` flag to the above command.
-### Install using `git`
-```
-git clone https://github.com/xenova/chat-downloader.git
-cd chat-downloader
-python setup.py install
-```
-
-## Usage
-Chat items are parsed into JSON objects (a.k.a. dictionaries). For a comprehensive, documented list of included fields, consult the [Chat Item Wiki](https://github.com/xenova/chat-downloader/wiki/Item-Template).
-
-### Command line:
-```
-chat_downloader https://www.youtube.com/watch?v=5qap5aO4i9A
-```
-
-For advanced command line use-cases and examples, consult the [Command Line Wiki](https://github.com/xenova/chat-downloader/wiki/Command-Line-Usage).
-
-
-### Python:
-```python
-from chat_downloader import ChatDownloader
-
-url = 'https://www.youtube.com/watch?v=5qap5aO4i9A'
-chat = ChatDownloader().get_chat(url)       # create a generator
-for message in chat:                        # iterate over messages
-    print(chat.format(message))             # print the formatted message
-```
-For advanced python use-cases and examples, consult the [Python Wiki](https://github.com/xenova/chat-downloader/wiki/Python-Documentation).
-
-## Issues
-Found a bug or have a suggestion? File an issue [here](https://github.com/xenova/chat-downloader/issues/new/choose). To assist the developers in fixing the issue, please follow the issue template as closely as possible.
-
-## Contributing
-If you want to contribute to chat-downloader, be sure to follow the [contribution guidelines](https://github.com/xenova/chat-downloader/blob/master/CONTRIBUTING.md).
-
-
-## Supported sites:
-- YouTube.com - Livestreams, past broadcasts and premieres.
-- Twitch.tv - Livestreams, past broadcasts and clips.
-- Facebook.com (currently in development) - Livestreams and past broadcasts.
-
-## TODO list:
-- Finalise unit testing
-- Improve documentation
-- Add progress bar when duration is known
-- Add support for streams by username (i.e. currently live)
-- Websites to add:
-    - facebook.com (in progress)
-    - vimeo.com
-    - dlive.tv
-    - instagib.tv
-    - dailymotion.com
-    - reddit live
-    - younow.com
-- Add `--statistics` tag. This will show a summary of all chat messages retrieved (e.g. sum YouTube superchat, memberships, subscriptions, etc.)
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..7331945
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,183 @@
+..
+    TODO
+    - temp move ... move back to root
+    - auto-generate using other rst files
+
+***************
+Chat Downloader
+***************
+
+.. image:: https://img.shields.io/pypi/pyversions/chat-downloader
+   :target: https://pypi.org/project/chat-downloader
+   :alt: Python
+.. image:: https://img.shields.io/pypi/v/chat-downloader.svg
+   :target: https://pypi.org/project/chat-downloader
+   :alt: PyPI version
+.. image:: https://pepy.tech/badge/chat-downloader
+   :target: https://pypi.org/project/chat-downloader
+   :alt: Downloads
+.. image:: https://img.shields.io/github/license/xenova/chat-downloader
+  :target: https://github.com/xenova/chat-downloader/blob/master/LICENSE
+  :alt: License
+
+..
+    [![PyPI Downloads](https://img.shields.io/pypi/dm/chat-downloader)](https://pypi.org/project/chat-downloader)
+    [![GitHub issues](https://img.shields.io/github/issues/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
+    [![GitHub forks](https://img.shields.io/github/forks/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
+    [![GitHub stars](https://img.shields.io/github/stars/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
+    [![Downloads](https://img.shields.io/github/downloads/xenova/chat-downloader/total.svg)](https://github.com/xenova/chat-downloader/releases)
+
+`Chat Downloader`_ is a simple tool used to retrieve chat messages from livestreams,
+videos, clips and past broadcasts. No authentication needed!
+
+.. _Chat Downloader: https://github.com/xenova/chat-downloader
+
+############
+Installation
+############
+
+This tool is distributed on PyPI_ and can be installed with ``pip``:
+
+.. _PyPI: https://pypi.org/project/chat-downloader/
+
+.. code:: console
+
+   $ pip install chat-downloader
+
+To update to the latest version, add the ``--upgrade`` flag to the above command.
+
+Alternatively, the tool can be installed with ``git``:
+
+.. code:: console
+
+   $ git clone https://github.com/xenova/chat-downloader.git
+   $ cd chat-downloader
+   $ python setup.py install
+
+
+#####
+Usage
+#####
+
+
+Command line
+------------
+
+.. code:: console
+
+    usage: chat_downloader [-h] [--version] [--start_time START_TIME]
+                           [--end_time END_TIME]
+                           [--message_types MESSAGE_TYPES | --message_groups MESSAGE_GROUPS]
+                           [--max_attempts MAX_ATTEMPTS]
+                           [--retry_timeout RETRY_TIMEOUT]
+                           [--max_messages MAX_MESSAGES]
+                           [--inactivity_timeout INACTIVITY_TIMEOUT]
+                           [--timeout TIMEOUT] [--format FORMAT]
+                           [--format_file FORMAT_FILE] [--chat_type {live,top}]
+                           [--ignore IGNORE]
+                           [--message_receive_timeout MESSAGE_RECEIVE_TIMEOUT]
+                           [--buffer_size BUFFER_SIZE] [--output OUTPUT]
+                           [--overwrite] [--sort_keys] [--indent INDENT]
+                           [--pause_on_debug | --exit_on_debug]
+                           [--logging {none,debug,info,warning,error,critical} | --testing | --verbose | --quiet]
+                           [--cookies COOKIES] [--proxy PROXY]
+                           url
+
+
+For example, to save messages from a livestream to a JSON file, you can use:
+
+.. code:: console
+
+   $ chat_downloader https://www.youtube.com/watch?v=5qap5aO4i9A --output chat.json
+
+
+
+For a description of these options, as well as advanced command line use-cases and examples, consult the `Command Line Usage <https://chat-downloader.readthedocs.io/en/latest/cli.html#command-line-usage>`_ page.
+
+
+Python
+------
+
+.. code:: python
+
+   from chat_downloader import ChatDownloader
+
+   url = 'https://www.youtube.com/watch?v=5qap5aO4i9A'
+   chat = ChatDownloader().get_chat(url)       # create a generator
+   for message in chat:                        # iterate over messages
+       chat.print_formatted(message)           # print the formatted message
+
+
+For advanced python use-cases and examples, consult the `Python Documentation <https://chat-downloader.readthedocs.io/en/latest/source/index.html#python-documentation>`_.
+
+
+##########
+Chat Items
+##########
+
+Chat items/messages are parsed into JSON objects (a.k.a. dictionaries) and should follow a format similar to this:
+
+.. code-block::
+
+    {
+        ...
+        "message_id": "xxxxxxxxxx",
+        "message": "actual message goes here",
+        "message_type": "text_message",
+        "timestamp": 1613761152565924,
+        "time_in_seconds": 1234.56,
+        "time_text": "20:34",
+        "author": {
+            "id": "UCxxxxxxxxxxxxxxxxxxxxxxx",
+            "name": "username_of_sender",
+            "images": [
+                ...
+            ],
+            "badges": [
+                ...
+            ]
+        },
+        ...
+    }
+
+
+For an extensive, documented list of included fields, consult the `Chat Item Fields <https://chat-downloader.readthedocs.io/en/latest/items.html#chat-item-fields>`_ page.
+
+##########################
+Frequently Asked Questions
+##########################
+
+*Coming soon*
+
+######
+Issues
+######
+
+Found a bug or have a suggestion? File an issue `here`_. To assist the
+developers in fixing the issue, please follow the issue template as
+closely as possible.
+
+.. _here: https://github.com/xenova/chat-downloader/issues/new/choose
+
+
+############
+Contributing
+############
+
+If you would like to help improve the tool, you'll find more
+information on contributing in our `Contributing Guide <https://chat-downloader.readthedocs.io/en/latest/contributing.html#contributing-guide>`_.
+
+
+################
+Supported sites:
+################
+
+-  YouTube.com - Livestreams, past broadcasts and premieres.
+-  Twitch.tv - Livestreams, past broadcasts and clips.
+-  Facebook.com (currently in development) - Livestreams and past
+   broadcasts.
+
+.. _Chat Item Wiki: https://github.com/xenova/chat-downloader/wiki/Item-Template
+.. _Command Line Wiki: https://github.com/xenova/chat-downloader/wiki/Command-Line-Usage
+.. _Python Wiki: https://github.com/xenova/chat-downloader/wiki/Python-Documentation
+
diff --git a/TODO b/TODO
new file mode 100644
index 0000000..4135bb6
--- /dev/null
+++ b/TODO
@@ -0,0 +1,21 @@
+##########
+TODO list:
+##########
+
+-  Finalise unit testing
+-  Improve documentation
+-  Add progress bar when duration is known
+-  Add support for streams by username (i.e. currently live)
+-  Websites to add:
+
+   -  facebook.com (in progress)
+   -  vimeo.com
+   -  dlive.tv
+   -  instagib.tv
+   -  dailymotion.com
+   -  reddit live
+   -  younow.com
+
+-  Add ``--statistics`` tag. This will show a summary of all chat
+   messages retrieved (e.g. sum YouTube superchat, memberships,
+   subscriptions, etc.)
diff --git a/chat_downloader/chat_downloader.py b/chat_downloader/chat_downloader.py
index 4d2b1e3..c88cc46 100644
--- a/chat_downloader/chat_downloader.py
+++ b/chat_downloader/chat_downloader.py
@@ -8,20 +8,25 @@
 
 from .metadata import __version__
 
-from .sites.common import SiteDefault
+from .sites.common import (
+    SiteDefault,
+    BaseChatDownloader
+)
 from .sites import get_all_sites
 
 from .formatting.format import ItemFormatter
 from .utils import (
-    log,
-    get_logger,
     safe_print,
-    set_log_level,
     get_default_args,
     update_dict_without_overwrite,
     TimedGenerator
 )
 
+from .debugging import (
+    log,
+    disable_logger,
+    set_log_level
+)
 
 from .output.continuous_write import ContinuousWriter
 
@@ -41,105 +46,187 @@
     InvalidParameter,
     InvalidURL,
     RetriesExceeded,
-    NoContinuation
+    NoContinuation,
+    UserNotFound,
+    ChatGeneratorError
 )
 
 
 class ChatDownloader():
+    """Class used to create sessions and download chats."""
 
     def __init__(self,
                  headers=None,
                  cookies=None,
                  proxy=None,
                  ):
+        """Initialise a new session for making requests. Parameters are saved
+        and are sent to the relevant constructor when creating a new session.
+
+        :param headers: Headers to use for subsequent requests, defaults to None
+        :type headers: dict, optional
+        :param cookies: Path of cookies file, defaults to None
+        :type cookies: str, optional
+        :param proxy: Use the specified HTTP/HTTPS/SOCKS proxy. To enable SOCKS
+            proxy, specify a proper scheme. For example socks5://127.0.0.1:1080/.
+            Pass in an empty string (--proxy "") for direct connection. Defaults
+            to None
+        :type proxy: str, optional
         """
-        Initialise a new session for making requests.
 
-        :param headers: Test headers
-        :param cookies: Path of cookies file
-        :param proxy: Use the specified HTTP/HTTPS/SOCKS proxy. To enable SOCKS proxy, specify a proper scheme. For example socks5://127.0.0.1:1080/. Pass in an empty string (--proxy "") for direct connection. Default is None (i.e. do not use a proxy)
-
-        """
         self.init_params = locals()
         self.init_params.pop('self')
 
-        # Track a dict of sessions
+        log('debug', 'Python version: {}'.format(sys.version))
+        log('debug', 'Program version: {}'.format(__version__))
+        log('debug', 'Initialisation parameters: {}'.format(self.init_params))
+
+        # Track sessions using a dictionary (allows for reusing)
         self.sessions = {}
 
     def get_chat(self, url=None,
                  start_time=None,
                  end_time=None,
-                 max_attempts=15,  # ~ 2^15s ~ 9 hours
+                 max_attempts=15,
                  retry_timeout=None,
                  timeout=None,
+                 inactivity_timeout=None,
                  max_messages=None,
 
-                 logging='info',
+                 # Logging/debugging
+                 logging='none',
                  pause_on_debug=False,
                  exit_on_debug=False,
-
-                 # If True, program will not sleep when a timeout instruction is given
-                 # force_no_timeout=False,
-                 # :param force_no_timeout: Force no timeout between subsequent requests
-                 #  force_encoding=None, # use default
-
-                 inactivity_timeout=None,
+                 testing=False,
+                 verbose=False,
+                 quiet=False,
 
                  message_groups=SiteDefault('message_groups'),
-                 message_types=None,  # SiteDefault('message_types'),
+                 message_types=None,
 
+                 # Output
+                 output=None,
+                 overwrite=True,
+                 sort_keys=True,
+                 indent=4,
 
                  # Formatting
-                 format=SiteDefault('format'),  # Use default
+                 format=SiteDefault('format'),
                  format_file=None,
 
                  # YouTube
                  chat_type='live',
+                 ignore=None,
 
                  # Twitch
                  message_receive_timeout=0.1,
                  buffer_size=4096
                  ):
+        """Used to get chat messages from a livestream, video, clip or past broadcast.
+
+        :param url: The URL of the livestream, video, clip or past broadcast,
+            defaults to None
+        :type url: str, optional
+        :param start_time: Start time in seconds or hh:mm:ss, defaults
+            to None (as early as possible)
+        :type start_time: float, optional
+        :param end_time: End time in seconds or hh:mm:ss, defaults to
+            None (until the end)
+        :type end_time: float, optional
+        :param max_attempts: Maximum number of attempts to retrieve chat
+            messages, defaults to 15
+        :type max_attempts: int, optional
+        :param retry_timeout: Number of seconds to wait before retrying. Setting
+            this to a negative number will wait for user input. Default is None
+            (use exponential backoff, i.e. immediate, 1s, 2s, 4s, 8s, ...)
+        :type retry_timeout: float, optional
+        :param timeout: Stop retrieving chat after a certain duration
+            (in seconds), defaults to None
+        :type timeout: float, optional
+        :param inactivity_timeout: Stop getting messages after not receiving
+            anything for a certain duration (in seconds), defaults to None
+        :type inactivity_timeout: float, optional
+        :param max_messages: Maximum number of messages to retrieve, defaults
+            to None (unlimited)
+        :type max_messages: int, optional
+        :param logging: Level of logging to display, defaults to 'none'
+            ('info' if run from the CLI)
+        :type logging: str, optional
+        :param pause_on_debug: Pause on certain debug messages, defaults to False
+        :type pause_on_debug: bool, optional
+        :param exit_on_debug: Exit when something unexpected happens, defaults
+            to False
+        :type exit_on_debug: bool, optional
+        :param testing: Enable testing mode. This is equivalent to setting
+            logging to debug and enabling pause_on_debug. Defaults to False
+        :type testing: bool, optional
+        :param verbose: Print various debugging information. This is equivalent
+            to setting logging to debug. Defaults to False
+        :type verbose: bool, optional
+        :param quiet: Activate quiet mode (hide all output), defaults to False
+        :type quiet: bool, optional
+        :param message_groups: List of messages groups (a predefined,
+            site-specific collection of message types) to include
+        :type message_groups: SiteDefault, optional
+        :param message_types: List of messages types to include, defaults to None
+        :type message_types: list, optional
+        :param output: Path of the output file, defaults to None (print to
+            standard output)
+        :type output: str, optional
+        :param overwrite: If True, overwrite output file. Otherwise, append
+            to the end of the file. Defaults to True. In both cases, the file
+            (and directories) is created if it does not exist.
+        :type overwrite: bool, optional
+        :param sort_keys: Sort keys when outputting to a file, defaults to True
+        :type sort_keys: bool, optional
+        :param indent: Number of spaces to indent JSON objects by. If
+            nonnumerical input is provided, this will be used to indent
+            the objects. Defaults to 4
+        :type indent: Union[int, str], optional
+        :param format: Specify how messages should be formatted for printing,
+            defaults to the site's default value
+        :type format: SiteDefault, optional
+        :param format_file: Specify the path of the format file to choose
+            formats from, defaults to None
+        :type format_file: str, optional
+        :param chat_type: Specify chat type, defaults to 'live'
+        :type chat_type: str, optional
+        :param ignore: Ignore a list of video ids, defaults to None
+        :type ignore: list, optional
+        :param message_receive_timeout: Time before requesting for new messages,
+            defaults to 0.1
+        :type message_receive_timeout: float, optional
+        :param buffer_size: Specify a buffer size for retrieving messages,
+            defaults to 4096
+        :type buffer_size: int, optional
+        :raises URLNotProvided: if no URL is provided
+        :raises ChatGeneratorError: if no valid generator can be found for a site
+        :raises SiteNotSupported: if no matching site can be found
+        :raises InvalidURL: if the URL provided is not valid
+        :return: The appropriate Chat object, given these parameters
+        :rtype: Chat
         """
-        Short description
-
-        Long description spanning multiple lines
-        - First line
-        - Second line
-        - Third line
-
-        :param url: The URL of the livestream, video, clip or past broadcast
-        :param start_time: Start time in seconds or hh:mm:ss, default is None (as early as possible)
-        :param end_time: End time in seconds or hh:mm:ss, default is None (until the end)
-
-        :param message_types: List of messages types to include
-        :param message_groups: List of messages groups (a predefined, site-specific collection of message types) to include
 
+        # TODO params to add
+        # If True, program will not sleep when a timeout instruction is given
+        # force_no_timeout=False,
+        # :param force_no_timeout: Force no timeout between subsequent requests
+        #  force_encoding=None, # use default
 
+        if not url:
+            raise URLNotProvided('No URL provided.')
 
-        :param max_attempts: Maximum number of attempts to retrieve chat messages
-        :param retry_timeout: Number of seconds to wait before retrying. Setting this to a negative number will wait for user input.
-        Default is None (use exponential backoff, i.e. immediate, 1s, 2s, 4s, 8s, ...)
-
-        :param max_messages: Maximum number of messages to retrieve, default is None (unlimited)
-        :param inactivity_timeout: Stop getting messages after not receiving anything for a certain duration (in seconds)
-        :param timeout: Stop retrieving chat after a certain duration (in seconds)
-
-        :param format: Specify how messages should be formatted for printing, default uses site default
-        :param format_file: Specify the format file to choose formats from
-
-        :param pause_on_debug: Pause on certain debug messages
-        :param exit_on_debug: Exit when something unexpected happens
-        :param logging: Level of logging to display
-
-        :param chat_type: Specify chat type, default is live
+        if testing:
+            logging = 'debug'
+            pause_on_debug = True
 
-        :param message_receive_timeout: Time before requesting for new messages
-        :param buffer_size: Specify a buffer size for retrieving messages
-        """
+        if verbose:
+            logging = 'debug'
 
-        if not url:
-            raise URLNotProvided('No URL provided.')
+        if quiet or logging == 'none':
+            disable_logger()
+        else:
+            set_log_level(logging)
 
         original_params = locals()
         original_params.pop('self')
@@ -148,30 +235,44 @@ def get_chat(self, url=None,
         # get corresponding website parser
         # based on matching url with predefined regex
         for site in get_all_sites():
-            regex = getattr(site, '_VALID_URL')
-            if isinstance(regex, str) and re.search(regex, url):  # regex has been set (not None)
+            match_info = site.matches(url)
+            if match_info:  # match found
 
-                # Create new session if not already created
-                if site.__name__ not in self.sessions:
-                    self.sessions[site.__name__] = site(**self.init_params)
+                function_name, match = match_info
+
+                # Create new session
+                self.create_session(site)
+                site_object = self.sessions[site.__name__]
 
                 # Parse site-defaults
                 params = {}
                 for k, v in original_params.items():
-                    params[k] = self.sessions[site.__name__].get_site_value(v)
+                    params[k] = site_object.get_site_value(v)
 
-                log('info', 'Site: {}'.format(
-                    self.sessions[site.__name__]._NAME))
+                log('info', 'Site: {}'.format(site_object._NAME))
                 log('debug', 'Program parameters: {}'.format(params))
-                info = self.sessions[site.__name__].get_chat(**params)
+
+                get_chat = getattr(site_object, function_name, None)
+                if not get_chat:
+                    raise NotImplementedError(
+                        '{} has not been implemented in {}.'.format(function_name, site.__name__))
+                chat = get_chat(match, params)
+                log('debug', 'Match found: "{}". Running "{}" function in "{}".'.format(
+                    match, function_name, site.__name__))
+
+                if chat is None:
+                    raise ChatGeneratorError(
+                        'No valid generator found in {} for url "{}"'.format(
+                            site.__name__, url))
+
                 if isinstance(max_messages, int):
-                    info.chat = itertools.islice(info.chat, max_messages)
+                    chat.chat = itertools.islice(chat.chat, max_messages)
 
                 if timeout is not None or inactivity_timeout is not None:
                     # Generator requires timing functionality
 
-                    info.chat = TimedGenerator(
-                        info.chat, timeout, inactivity_timeout)
+                    chat.chat = TimedGenerator(
+                        chat.chat, timeout, inactivity_timeout)
 
                     if isinstance(timeout, (float, int)):
                         start = time.time()
@@ -179,52 +280,79 @@ def get_chat(self, url=None,
                         def log_on_timeout():
                             log('debug', 'Timeout occurred after {} seconds.'.format(
                                 time.time() - start))
-                        setattr(info.chat, 'on_timeout', log_on_timeout)
+                        setattr(chat.chat, 'on_timeout', log_on_timeout)
 
                     if isinstance(inactivity_timeout, (float, int)):
                         def log_on_inactivity_timeout():
                             log('debug', 'Inactivity timeout occurred after {} seconds.'.format(
                                 inactivity_timeout))
-                        setattr(info.chat, 'on_inactivity_timeout',
+                        setattr(chat.chat, 'on_inactivity_timeout',
                                 log_on_inactivity_timeout)
 
-                info.site = self.sessions[site.__name__]
+                formatter = ItemFormatter(format_file)
+                chat.format = lambda x: formatter.format(x, format_name=format)
+
+                if output:
+                    output_file = ContinuousWriter(
+                        output, indent=indent, sort_keys=sort_keys, overwrite=overwrite)
 
-                formatter = ItemFormatter(params['format_file'])
-                info.format = lambda x: formatter.format(
-                    x, format_name=params['format'])
+                    if output_file.is_default():
+                        chat.callback = lambda x: output_file.write(chat.format(x), flush=True)
+                    else:
+                        chat.callback = lambda x: output_file.write(x, flush=True)
 
-                return info
+                chat.site = site_object
+
+                log('debug', 'Chat information: {}'.format(chat.__dict__))
+                log('info', 'Retrieving chat for "{}".'.format(chat.title))
+
+                return chat
 
         parsed = urlparse(url)
+        log('debug', str(parsed))
 
-        if parsed.scheme:
-            log('debug', str(parsed))
+        if parsed.netloc:
             raise SiteNotSupported(
                 'Site not supported: {}'.format(parsed.netloc))
-        else:
-            original_params['url'] = 'https://' + url  # try to correct
+        elif not parsed.scheme:  # No scheme, try to correct
+            original_params['url'] = 'https://' + url
             chat = self.get_chat(**original_params)
             if chat:
                 return chat
-
-            log('debug', parsed)
+        else:
             raise InvalidURL('Invalid URL: "{}"'.format(url))
 
+    def create_session(self, chat_downloader_class, overwrite=False):
+        if not issubclass(chat_downloader_class, BaseChatDownloader):
+            raise TypeError('Unable to create session, class must extend BaseChatDownloader. Class given: {}'.format(
+                chat_downloader_class))
+        elif chat_downloader_class == BaseChatDownloader:
+            raise TypeError(
+                'Unable to create session, class may not be BaseChatDownloader.')
+
+        session_name = chat_downloader_class.__name__
+        log('debug', 'Created {} session.'.format(session_name))
+
+        if session_name not in self.sessions or overwrite:
+            self.sessions[session_name] = chat_downloader_class(
+                **self.init_params)
+
+        return self.sessions[session_name]
+
+    def get_session(self, chat_downloader_class):
+        return self.sessions.get(chat_downloader_class.__name__)
+
     def close(self):
+        """Close all sessions associated with the object"""
         for session in self.sessions.values():
             session.close()
 
         self.sessions = {}
 
 
-def run(**kwargs):
+def run(propagate_interrupt=False, **kwargs):
     """
     Create a single session and get the chat using the specified parameters.
-
-    e.g.
-    >>> run(url='https://www.youtube.com/watch?v=5qap5aO4i9A')
-
     """
 
     init_param_names = get_default_args(ChatDownloader.__init__)
@@ -233,23 +361,6 @@ def run(**kwargs):
     update_dict_without_overwrite(kwargs, init_param_names)
     update_dict_without_overwrite(kwargs, program_param_names)
 
-    if kwargs.get('testing'):
-        kwargs['logging'] = 'debug'
-        kwargs['pause_on_debug'] = True
-        # args.message_groups = 'all'
-        # program_params['timeout = 180
-
-    if kwargs.get('verbose'):
-        kwargs['logging'] = 'debug'
-
-    quiet = kwargs.get('quiet')
-    if quiet or kwargs['logging'] == 'none':
-        get_logger().disabled = True
-    else:
-        set_log_level(kwargs['logging'])
-
-    output = kwargs.get('output')
-
     chat_params = {}
     init_params = {}
 
@@ -261,38 +372,17 @@ def run(**kwargs):
         elif arg in init_param_names:
             init_params[arg] = value
 
-    log('debug', 'Python version: {}'.format(sys.version))
-    log('debug', 'Program version: {}'.format(__version__))
-
-    log('debug', 'Initialisation parameters: {}'.format(init_params))
-
     downloader = ChatDownloader(**init_params)
 
-    output_file = None
     try:
         chat = downloader.get_chat(**chat_params)
 
-        log('debug', 'Chat information: {}'.format(chat.__dict__))
-        log('info', 'Retrieving chat for "{}".'.format(chat.title))
-
-        def print_formatted(item):
-            if not quiet:
-                formatted = chat.format(item)
-                safe_print(formatted)
-
-        if output:
-            output_args = {
-                k: kwargs.get(k) for k in ('indent', 'sort_keys', 'overwrite')
-            }
-            output_file = ContinuousWriter(output, **output_args)
-
-            def write_to_file(item):
-                print_formatted(item)
-                output_file.write(item, flush=True)
-
-            callback = write_to_file
+        if kwargs.get('quiet'):  # Only check if quiet once
+            def callback(item):
+                pass
         else:
-            callback = print_formatted
+            def callback(item):
+                safe_print(chat.format(item), flush=True)
 
         for message in chat:
             callback(message)
@@ -310,11 +400,15 @@ def write_to_file(item):
         InvalidParameter,
         InvalidURL,
         RetriesExceeded,
-        NoContinuation
+        NoContinuation,
+        UserNotFound
     ) as e:  # Expected errors
         log('error', e)
-        # log('error', e, logging_level)  # always show
-        # '{} ({})'.format(, e.__class__.__name__)
+    except (
+        ChatGeneratorError
+    ) as e:  # Errors which may be bugs
+        log('error', '{}. {}'.format(
+            e, 'Please report this at https://github.com/xenova/chat-downloader/issues/new/choose'))
 
     except ConnectionError as e:
         log('error', 'Unable to establish a connection. Please check your internet connection. {}'.format(e))
@@ -323,13 +417,10 @@ def write_to_file(item):
         log('error', e)
 
     except KeyboardInterrupt as e:
-        if kwargs.get('interruptible'):
+        if propagate_interrupt:
             raise e
         else:
             log('error', 'Keyboard Interrupt')
 
     finally:
         downloader.close()
-
-        if output and output_file:
-            output_file.close()
diff --git a/chat_downloader/cli.py b/chat_downloader/cli.py
index 9c2cf55..a8543df 100644
--- a/chat_downloader/cli.py
+++ b/chat_downloader/cli.py
@@ -9,7 +9,11 @@
     run
 )
 
-from .metadata import __version__
+from .metadata import (
+    __version__,
+    __summary__,
+    __program__
+)
 
 from .utils import (
     get_default_args,
@@ -20,16 +24,12 @@
 def main():
 
     parser = argparse.ArgumentParser(
-        description='A simple tool used to retrieve chat messages from livestreams, videos, clips and past broadcasts. No authentication needed!',
-        formatter_class=argparse.RawTextHelpFormatter,
+        description=__summary__,
+        # formatter_class=argparse.RawTextHelpFormatter,
     )
+    parser.prog = __program__
 
-    parser.add_argument('--version', action='version',
-                        version=__version__)
-
-    # PROGRAM PARAMS
-    parser.add_argument(
-        'url', help='The URL of the livestream, video, clip or past broadcast')
+    parser.add_argument('--version', action='version', version=__version__)
 
     def get_info(function):
         info = {}
@@ -73,6 +73,8 @@ def add_chat_param(group, *keys, **kwargs):
     def add_init_param(group, *keys, **kwargs):
         add_param('init', group, *keys, **kwargs)
 
+    add_chat_param(parser, 'url')
+
     time_group = parser.add_argument_group('Timing Arguments')
 
     add_chat_param(time_group, '--start_time', '-s')
@@ -108,6 +110,7 @@ def splitter(s):
 
     # Formatting
     format_group = parser.add_argument_group('Format Arguments')
+    # format_group.add_argument('--print_json', action='store_true', help='Print out json ', default=None)
     add_chat_param(format_group, '--format')
     add_chat_param(format_group, '--format_file')
 
@@ -116,6 +119,7 @@ def splitter(s):
         '[Site Specific] YouTube Arguments')
     add_chat_param(youtube_group, '--chat_type',
                    choices=['live', 'top'])
+    add_chat_param(youtube_group, '--ignore', type=splitter)
     # add_chat_param(
     #     youtube_group, '--force_no_timeout', action='store_true')
 
@@ -127,14 +131,10 @@ def splitter(s):
     add_chat_param(twitch_group, '--buffer_size', type=int)
 
     output_group = parser.add_argument_group('Output Arguments')
-    output_group.add_argument(
-        '--output', '-o', help='Path of the output file, default is None (i.e. print to standard output)', default=None)
-    output_group.add_argument(
-        '--sort_keys', help='Sort keys when outputting to a file', action='store_false')
-    output_group.add_argument('--indent', type=lambda x: int_or_none(x, x),
-                              help='Number of spaces to indent JSON objects by. If nonnumerical input is provided, this will be used to indent the objects.', default=4)
-    output_group.add_argument(
-        '--overwrite', help='Overwrite output file if it exists. Otherwise, append to the end of the file.', action='store_true')
+    add_chat_param(output_group, '--output', '-o')
+    add_chat_param(output_group, '--overwrite', is_boolean_flag=True)
+    add_chat_param(output_group, '--sort_keys', is_boolean_flag=True)
+    add_chat_param(output_group, '--indent', type=lambda x: int_or_none(x, x))
 
     debug_group = parser.add_argument_group('Debugging/Testing Arguments')
 
@@ -143,15 +143,18 @@ def splitter(s):
     add_chat_param(on_debug_options, '--exit_on_debug', is_boolean_flag=True)
 
     debug_options = debug_group.add_mutually_exclusive_group()
+
+    # overwrite default from method
+    get_chat_info['logging']['default'] = 'info'
+
     add_chat_param(debug_options, '--logging',
                    choices=['none', 'debug', 'info', 'warning', 'error', 'critical'])
 
-    debug_options.add_argument(
-        '--testing', help='Enable testing mode. This is equivalent to setting logging to debug and enabling pause_on_debug', action='store_true')
-    debug_options.add_argument(
-        '--verbose', '-v', help='Print various debugging information. This is equivalent to setting logging to debug', action='store_true')
-    debug_options.add_argument(
-        '--quiet', '-q', help='Activate quiet mode (hide all output)', action='store_true')
+    add_chat_param(debug_options, '--testing', is_boolean_flag=True)
+    add_chat_param(debug_options, '--verbose', '-v', is_boolean_flag=True)
+    add_chat_param(debug_options, '--quiet', '-q', is_boolean_flag=True)
+
+    # TODO Add --do_not_print option
 
     # INIT PARAMS
     init_group = parser.add_argument_group('Initialisation Arguments')
@@ -165,6 +168,5 @@ def splitter(s):
 
     args = parser.parse_args()
 
-    # Finished parsing CLI arguments
     # Run with these arguments
     run(**args.__dict__)
diff --git a/chat_downloader/debugging.py b/chat_downloader/debugging.py
new file mode 100644
index 0000000..2efc170
--- /dev/null
+++ b/chat_downloader/debugging.py
@@ -0,0 +1,96 @@
+"""Debugging module for chat_downloader"""
+
+import sys
+import os
+import colorlog
+
+import chat_downloader
+
+# Source: https://github.com/django/django/blob/master/django/core/management/color.py
+def supports_colour():
+    """
+    Return True if the running system's terminal supports colour,
+    and False otherwise.
+    """
+    def vt_codes_enabled_in_windows_registry():
+        """
+        Check the Windows Registry to see if VT code handling has been enabled
+        by default, see https://superuser.com/a/1300251/447564.
+        """
+        try:
+            # winreg is only available on Windows.
+            import winreg
+        except ImportError:
+            return False
+        else:
+            reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Console')
+            try:
+                reg_key_value, _ = winreg.QueryValueEx(
+                    reg_key, 'VirtualTerminalLevel')
+            except FileNotFoundError:
+                return False
+            else:
+                return reg_key_value == 1
+
+    # isatty is not always implemented, #6223.
+    is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
+
+    return is_a_tty and (
+        # Windows Terminal supports VT codes.
+        # Microsoft Visual Studio Code's built-in terminal supports colours.
+
+        (sys.platform != 'win32') or ('ANSICON' in os.environ) or ('WT_SESSION' in os.environ) or (
+            os.environ.get('TERM_PROGRAM') == 'vscode') or (vt_codes_enabled_in_windows_registry())
+    )
+
+
+if supports_colour():
+    handler = colorlog.StreamHandler()
+    handler.setFormatter(colorlog.ColoredFormatter(
+        '[%(log_color)s%(levelname)s%(reset)s] %(message)s',
+        log_colors={
+            'DEBUG': 'cyan',
+            'INFO': 'green',
+            'WARNING': 'yellow',
+            'ERROR': 'red',
+            'CRITICAL': 'bold_red',
+        })
+    )
+    log_module = colorlog
+
+else:  # fallback support
+    import logging
+    handler = logging.StreamHandler()
+    handler.setFormatter(logging.Formatter('[%(levelname)s] %(message)s'))
+    log_module = logging
+
+# Create logger object for this module
+logger_name = chat_downloader.__name__
+logger = log_module.getLogger(logger_name)
+
+# Define which loggers to display
+loggers = [log_module.getLogger(name) for name in (logger_name, 'urllib3')]
+for logger in loggers:
+    logger.addHandler(handler)
+
+
+def set_log_level(level):
+    level_name = level.upper()
+    for logger in loggers:
+        logger.setLevel(level_name)
+
+
+def disable_logger():
+    logger.disabled = True
+
+
+def log(level, items, to_pause=False):
+    logger_at_level = getattr(logger, level, None)
+    if logger_at_level:
+        if not isinstance(items, (tuple, list)):
+            items = [items]
+        for item in items:
+            logger_at_level(item)
+
+        if to_pause:
+            input('Press Enter to continue...')
diff --git a/chat_downloader/errors.py b/chat_downloader/errors.py
index 8cbb2a1..bbc4e91 100644
--- a/chat_downloader/errors.py
+++ b/chat_downloader/errors.py
@@ -1,78 +1,90 @@
 """File for defining errors"""
 
 
-class UnexpectedError(Exception):
+class ChatDownloaderError(Exception):
+    """Base class for Chat Downloader errors."""
+
+
+class UnexpectedError(ChatDownloaderError):
     """Raised if something unexpected happens."""
 
     def __init__(self, items):
         super().__init__(str(items))
 
 
-class InvalidParameter(Exception):
+class InvalidParameter(ChatDownloaderError):
     """Raised if an invalid parameter is specified."""
     pass
 
 
-class RetriesExceeded(Exception):
+class RetriesExceeded(ChatDownloaderError):
     """Raised after the maximum number of retries has been reached."""
     pass
 
 
-class VideoNotFound(Exception):
+class VideoNotFound(ChatDownloaderError):
     """Raised when video cannot be found."""
     pass
 
+class UserNotFound(ChatDownloaderError):
+    """Raised when user cannot be found."""
+    pass
 
-class ParsingError(Exception):
+class ParsingError(ChatDownloaderError):
     """Raised when video data cannot be parsed."""
     pass
 
 
-class VideoUnavailable(Exception):
+class VideoUnavailable(ChatDownloaderError):
     """Raised when video is unavailable."""
     pass
 
 
-class LoginRequired(Exception):
+class LoginRequired(ChatDownloaderError):
     """Raised when video is login is required (e.g. if video is private)."""
     pass
 
 
-class VideoUnplayable(Exception):
+class VideoUnplayable(ChatDownloaderError):
     """Raised when video is unplayable (e.g. if video is members-only)."""
     pass
 
 
-class NoChatReplay(Exception):
+class NoChatReplay(ChatDownloaderError):
     """Raised when the video does not contain a chat replay."""
     pass
 
 
-class URLNotProvided(Exception):
+class URLNotProvided(ChatDownloaderError):
     """Raised when no url is provided."""
     pass
 
 
-class InvalidURL(Exception):
+class InvalidURL(ChatDownloaderError):
     """Raised when the url is invalid."""
     pass
 
 
-class SiteNotSupported(Exception):
+class ChatGeneratorError(ChatDownloaderError):
+    """Raised when no valid generator method for a site can be found."""
+    pass
+
+
+class SiteNotSupported(ChatDownloaderError):
     """Raised when the url is valid, but the site is not supported."""
     pass
 
 
-class TwitchError(Exception):
+class TwitchError(ChatDownloaderError):
     """Raised when an error occurs with a Twitch video."""
     pass
 
 
-class NoContinuation(Exception):
+class NoContinuation(ChatDownloaderError):
     """Raised when no continuation can be found."""
     pass
 
 
-class CookieError(Exception):
+class CookieError(ChatDownloaderError):
     """Raised when an error occurs while loading a cookie file."""
     pass
diff --git a/chat_downloader/formatting/format.py b/chat_downloader/formatting/format.py
index 916f226..2d32755 100644
--- a/chat_downloader/formatting/format.py
+++ b/chat_downloader/formatting/format.py
@@ -11,12 +11,18 @@
 
 
 class ItemFormatter:
+    """Class used to control the formatting of chat items."""
 
     _INDEX_REGEX = r'(?<!\\){(.+?)(?<!\\)}'
 
     # 'always_show': True (default False)
 
     def __init__(self, path=None):
+        """Create an ItemFormatter object
+
+        :param path: Path of the format file, defaults to None
+        :type path: str, optional
+        """
 
         if path is None or not os.path.exists(path):
             path = os.path.join(os.path.dirname(
@@ -25,51 +31,79 @@ def __init__(self, path=None):
         with open(path) as custom_formats:
             self.format_file = json.load(custom_formats)
 
-    def replace(self, result, item, format_object):
-        split = result.group(1).split('|')
+    def _replace(self, match, item, format_object):
+        """Replace a match object with
+
+        :param match: The match object
+        :type match: re.Match
+        :param item: The chat item to choose the value to replace the key with
+        :type item: dict
+        :param format_object: The format object which defines how the
+            replacement should be done
+        :type format_object: dict
+        :return: The replacement value as a string
+        :rtype: str
+        """
+
+        split = match.group(1).split('|')
 
         for index in split:
             value = multi_get(item, *index.split('.'))
 
-            if value is not None:
-                formatting_info = format_object.get(index)
-                if formatting_info is not None:
-                    template = ''
-                    if isinstance(formatting_info, str):
-                        template = formatting_info
-                    elif isinstance(formatting_info, dict):
-                        template = formatting_info.get('template') or ''
-
-                        formatting = formatting_info.get('format')
-                        if formatting:
-                            if index == 'timestamp':
-                                value = microseconds_to_timestamp(
-                                    value, formatting)
-                            elif index == '...':
-                                pass
-
-                        # Apply separator
-                        separator = formatting_info.get('separator')
-                        if separator:
-                            if index == 'author.badges':
-                                value = separator.join(
-                                    map(lambda key: key.get('title'), value))
-                            elif isinstance(value, (tuple, list)):
-                                value = separator.join(
-                                    map(lambda x: str(x), value))
-                            else:
-                                pass
-                    else:
+            if value is None:
+                continue
+
+            formatting_info = format_object.get(index)
+            if formatting_info is None:
+                return str(value)
+
+            template = ''
+            if isinstance(formatting_info, str):
+                template = formatting_info
+            elif isinstance(formatting_info, dict):
+                template = formatting_info.get('template') or ''
+
+                formatting = formatting_info.get('format')
+                if formatting:
+                    if index == 'timestamp':
+                        value = microseconds_to_timestamp(
+                            value, formatting)
+                    elif index == '...': # TODO add others
                         pass
 
-                    return template.format(value)
+                # Apply separator
+                separator = formatting_info.get('separator')
+                if separator:
+                    if index == 'author.badges':
+                        value = separator.join(
+                            map(lambda key: key.get('title'), value))
+                    elif isinstance(value, (tuple, list)):
+                        value = separator.join(
+                            map(lambda x: str(x), value))
+                    else:
+                        pass
+            else:
+                pass
 
-                else:
-                    return str(value)
+            return template.format(value)
 
         return ''  # no match, return empty
 
     def format(self, item, format_name='default', format_object=None):
+        """Format a chat item according to a format (specified by its name),
+            found in the format_object
+
+        :param item: The chat item to be formatted
+        :type item: dict
+        :param format_name: The name of the format to be applied, defaults
+            to 'default'
+        :type format_name: str, optional
+        :param format_object: The format object from which the format will
+            be chosen, defaults to None
+        :type format_object: dict, optional
+        :return: The string representation of the chat item
+        :rtype: str
+        """
         default_format_object = self.format_file.get('default')
         if format_object is None:
             format_object = self.format_file.get(
@@ -105,11 +139,10 @@ def format(self, item, format_name='default', format_object=None):
             parent = self.format_file.get(inherit) or {}
             format_object = nested_update(deepcopy(parent), format_object)
 
-        # print('after',format_object)
         template = format_object.get('template') or ''
         keys = format_object.get('keys') or {}
 
-        substitution = re.sub(self._INDEX_REGEX, lambda result: self.replace(
-            result, item, keys), template)
+        substitution = re.sub(self._INDEX_REGEX, lambda match: self._replace(
+            match, item, keys), template)
 
         return substitution
diff --git a/chat_downloader/metadata.py b/chat_downloader/metadata.py
index e1b43ce..2d52160 100644
--- a/chat_downloader/metadata.py
+++ b/chat_downloader/metadata.py
@@ -1,8 +1,10 @@
 """Set metadata for chat-downloader"""
 
 __title__ = 'chat-downloader'
+__program__ = 'chat_downloader'
 __summary__ = 'A simple tool used to retrieve chat messages from livestreams, videos, clips and past broadcasts. No authentication needed!'
 __author__ = 'xenova'
 __email__ = 'admin@xenova.com'
+__copyright__ = '2020, 2021 xenova'
 __url__ = 'https://github.com/xenova/chat-downloader'
-__version__ = '0.0.8'
+__version__ = '0.1.0'
diff --git a/chat_downloader/output/continuous_write.py b/chat_downloader/output/continuous_write.py
index 946fef8..ccb184b 100644
--- a/chat_downloader/output/continuous_write.py
+++ b/chat_downloader/output/continuous_write.py
@@ -1,6 +1,7 @@
 import os
 import json
 import csv
+import shutil
 
 from ..utils import flatten_json
 
@@ -13,7 +14,14 @@ class CW:
     Otherwise, the writer can be explicitly closed.
     """
 
-    def __init__(self, file_name, overwrite=False):
+    def __init__(self, file_name, overwrite=True):
+        """Create a CW object.
+
+        :param file_name: The name of the file to write to
+        :type file_name: str
+        :param overwrite: Whether to overwrite if the file already exists, defaults to True
+        :type overwrite: bool, optional
+        """
         self.file_name = file_name
         # subclasses must set self.file
 
@@ -34,6 +42,16 @@ def __exit__(self, exc_type, exc_val, exc_tb):
         self.close()
 
     def write(self, item, flush=False):
+        """Write a chat item to the file. This method should be implemented in subclasses.
+
+        :param item: The chat item
+        :type item: dict
+        :param flush: Whether to force the file to be flushed after writing,
+            defaults to False
+        :type flush: bool, optional
+        :raises NotImplementedError: if the method is not implemented
+            and called from a subclass.
+        """
         raise NotImplementedError
 
     def flush(self):
@@ -45,7 +63,7 @@ class JSONCW(CW):
     Class used to control the continuous writing of a list of dictionaries to a JSON file.
     """
 
-    def __init__(self, file_name, overwrite=False, indent=None, separator=', ', indent_character=' ', sort_keys=True):
+    def __init__(self, file_name, overwrite=True, indent=None, separator=', ', indent_character=' ', sort_keys=True):
         super().__init__(file_name, overwrite)
         # open file for appending and reading in binary mode.
         self.file = open(self.file_name, 'rb+')
@@ -57,7 +75,9 @@ def __init__(self, file_name, overwrite=False, indent=None, separator=', ', inde
             try:
                 previous_items = json.load(self.file)
             except json.decoder.JSONDecodeError:
+                # TODO create .tmp, file shutil.copy(), self.file.read()
                 pass
+
         self.file.truncate(0)  # empty file
 
         self.indent = indent
@@ -110,7 +130,7 @@ class CSVCW(CW):
     Class used to control the continuous writing of a list of dictionaries to a CSV file.
     """
 
-    def __init__(self, file_name, overwrite=False, sort_keys=True):
+    def __init__(self, file_name, overwrite=True, sort_keys=True):
         super().__init__(file_name, overwrite)
 
         self.file = open(self.file_name, 'a+', newline='',
@@ -162,7 +182,7 @@ class TXTCW(CW):
     Class used to control the continuous writing of a text to a TXT file.
     """
 
-    def __init__(self, file_name, overwrite=False):
+    def __init__(self, file_name, overwrite=True):
         super().__init__(file_name, overwrite)
         self.file = open(self.file_name, 'a',
                          encoding='utf-8')  # , buffering=1
@@ -187,6 +207,9 @@ def __init__(self, file_name, **kwargs):
             key: kwargs[key] for key in kwargs if key in writer_class.__init__.__code__.co_varnames}
         self.writer = writer_class(file_name, **new_kwargs)
 
+    def is_default(self):
+        return isinstance(self.writer, TXTCW)
+
     def write(self, item, flush=False):
         self.writer.write(item, flush)
 
diff --git a/chat_downloader/sites/__init__.py b/chat_downloader/sites/__init__.py
index e30df65..0639cf1 100644
--- a/chat_downloader/sites/__init__.py
+++ b/chat_downloader/sites/__init__.py
@@ -7,6 +7,13 @@
 
 
 def get_all_sites(include_parent=False):
+    """Get all supported sites.
+
+    :param include_parent: Whether to include the BaseChatDownloader, defaults to False
+    :type include_parent: bool, optional
+    :return: A list of all supported ChatDownloader classes
+    :rtype: list
+    """
     return [
         value
         for value in globals().values()
diff --git a/chat_downloader/sites/common.py b/chat_downloader/sites/common.py
index ece7634..e2a428a 100644
--- a/chat_downloader/sites/common.py
+++ b/chat_downloader/sites/common.py
@@ -2,6 +2,7 @@
 import requests
 from http.cookiejar import MozillaCookieJar
 import os
+import re
 from json import JSONDecodeError
 
 from ..errors import (
@@ -13,14 +14,65 @@
 
 from ..utils import (
     get_title_of_webpage,
-    log,
     pause,
-    timed_input
+    timed_input,
+    safe_print
 )
 
+from ..debugging import log
+
+
+class Image():
+    def __init__(self, url, width=None, height=None, image_id=None):
+        """Create an Image object
+
+        :param url: The URL of the actual image
+        :type url: str
+        :param width: The width of the image, defaults to None
+        :type width: int, optional
+        :param height: The height of the image, defaults to None
+        :type height: int, optional
+        :param image_id: A identifier for the image, usually of the form: {width}x{height}, defaults to None
+        :type image_id: str, optional
+        """
+        self.url = url
+
+        if self.url.startswith('//'):
+            self.url = 'https:' + self.url
+
+        self.width = width
+        self.height = height
+
+        if width and height and not image_id:
+            self.id = '{}x{}'.format(width, height)
+        elif image_id:
+            self.id = image_id
+
+    def json(self):
+        """Return the JSON representation of an Image
+
+        :return: JSON representation of the object
+        :rtype: dict
+        """
+        return {k: v for k, v in self.__dict__.items() if v is not None}
+
 
 class Remapper():
+    """Class used to control the remapping of one dictionary to another dictionary."""
+
     def __init__(self, new_key=None, remap_function=None, to_unpack=False):
+        """Create a Remapper object
+
+        :param new_key: The new key of the item, defaults to None
+        :type new_key: str, optional
+        :param remap_function: The remapping function, defaults to None
+        :type remap_function: function, optional
+        :param to_unpack: Unpack the remapped item (to map to multiple output keys),
+            defaults to False
+        :type to_unpack: bool, optional
+        :raises ValueError: if unable to perform a remapping
+        """
+
         if new_key is not None and to_unpack:
             # New key is specified, but must unpack. Not allowed
             raise ValueError(
@@ -37,168 +89,186 @@ def __init__(self, new_key=None, remap_function=None, to_unpack=False):
         self.remap_function = remap_function
         self.to_unpack = to_unpack
 
+    @staticmethod
+    def remap(info, remapping_dict, remap_key, remap_input, keep_unknown_keys=False, replace_char_with_underscores=None):
+        """A function used to remap items from one dictionary to another
+
+        :param info: Output dictionary
+        :type info: dict
+        :param remapping_dict: Dictionary of remappings
+        :type remapping_dict: dict
+        :param remap_key: The key of the remapping
+        :type remap_key: str
+        :param remap_input: The input sent to the remapping function
+        :type remap_input: object
+        :param keep_unknown_keys: If no remapping is found, keep the data
+            with its original key and value. Defaults to False
+        :type keep_unknown_keys: bool, optional
+        :param replace_char_with_underscores: If no remapping is found,
+            replace a character in the key with underscores. Defaults to None
+        :type replace_char_with_underscores: str, optional
+        :raises ValueError: if attempting to unpack an item that is not a dictionary,
+            or if an unknown remapping is specified
+        """
+
+        remap = remapping_dict.get(remap_key)
+
+        if remap:  # A matching 'remapping' has been found, apply this remapping
+            if isinstance(remap, Remapper):
+                new_key = remap.new_key  # or remap_key
+
+                # Perform transformation
+                if remap.remap_function:  # Has a remap function
+                    new_value = remap.remap_function(remap_input)
+                else:  # No remap function specified, apply identity transformation
+                    new_value = remap_input
+
+                # Assign values to info
+                if not remap.to_unpack:
+                    info[new_key] = new_value
+                elif isinstance(new_value, dict):
+                    info.update(new_value)
+                else:
+                    raise ValueError(
+                        'Unable to unpack item which is not a dictionary.')
+
+            elif isinstance(remap, str):
+                # If it is just a string, simply assign the new value to this key
+                info[remap] = remap_input
+            else:
+                raise ValueError('Unknown remapping specified.')
+
+        elif keep_unknown_keys:
+            if replace_char_with_underscores:
+                remap_key = remap_key.replace(
+                    replace_char_with_underscores, '_')
+            info[remap_key] = remap_input
+
+    @staticmethod
+    def remap_dict(input_dictionary, remapping_dict, keep_unknown_keys=False, replace_char_with_underscores=None):
+        """Given an input dictionary and a remapping dictionary, return the remapped dictionary
+
+        :param input_dictionary: Input dictionary
+        :type input_dictionary: dict
+        :param remapping_dict: Dictionary of Remapper objects
+        :type remapping_dict: dict
+        :param keep_unknown_keys: If no remapping is found, keep the data
+            with its original key and value. Defaults to False
+        :type keep_unknown_keys: bool, optional
+        :param replace_char_with_underscores: If no remapping is found,
+            replace a character in the key with underscores. Defaults to None
+        :type replace_char_with_underscores: str, optional
+        :return: Remapped dictionary
+        :rtype: dict
+        """
+
+        info = {}
+        for key in input_dictionary:
+            Remapper.remap(
+                info, remapping_dict, key, input_dictionary[key],
+                keep_unknown_keys=keep_unknown_keys,
+                replace_char_with_underscores=replace_char_with_underscores
+            )
+        return info
+
 
 class SiteDefault:
-    # Used for site-default parameters
+    """Allows for sites to specify default parameters. Additionally, different
+    sites can specify different values for the same input parameter."""
+
     def __init__(self, name):
+        """Create a SiteDefault object
+
+        :param name: The key which will be checked in the `_SITE_DEFAULT_PARAMS`
+        dictionary to get the site's default value
+        :type name: str
+        """
         self.name = name
 
 
 class Chat():
-    def __init__(self, chat, **kwargs):
+    """Class used to manage all chat data for a single stream or video.
+
+    Classes that extend `BaseChatDownloader` contain the `get_chat` method,
+    which returns a `Chat` object. These objects are iterable, where the
+    next value is yielded from the object's `chat` generator method.
+    """
+
+    def __init__(self, chat=None, callback=None, title=None, duration=None, is_live=None, start_time=None, **kwargs):
+        """Create a Chat object
+
+        :param chat: Generator method for retrieving chat messages, defaults to None
+        :type chat: generator, optional
+        :param callback: Function to call on every message, defaults to None
+        :type callback: function, optional
+        :param title: Stream or video title, defaults to None
+        :type title: str, optional
+        :param duration: Duration of the stream or video, defaults to None
+        :type duration: float, optional
+        :param is_live: True if the stream is live, defaults to None
+        :type is_live: bool, optional
+        :param start_time: Start time of the stream (or upload date of video)
+            in UNIX microseconds, defaults to None
+        :type start_time: float, optional
+        """
+
         self.chat = chat
 
-        self.title = kwargs.get('title')
-        self.duration = kwargs.get('duration')
-        self.is_live = kwargs.get('is_live')
-        self.start_time = kwargs.get('start_time')
+        self.callback = callback
+        self.title = title
+        self.duration = duration
+        self.is_live = is_live
+        self.start_time = start_time
 
         # TODO
         # author/user/uploader/creator
 
     def __iter__(self):
-        yield from self.chat
+        """Allows the object to be iterable
+
+        :return: This object
+        :rtype: Chat
+        """
+        return self
+
+    def __next__(self):
+        """Get the next chat message from the generator
+
+        :return: The next chat item
+        :rtype: dict
+        """
+        item = next(self.chat)
+        if self.callback:
+            self.callback(item)
+        return item
+
+    def print_formatted(self, item):
+        """Safely print the formatted message
+
+        :param item: The chat item to be printed
+        :type item: dict
+        """
+        formatted = self.format(item)
+        safe_print(formatted)
 
-    # Must be set later
     def format(self, item):
+        """Format chat messages
+
+        :param item: The chat item to be formatted
+        :type item: dict
+        :raises NotImplementedError: if this method has not been overridden later
+        """
         raise NotImplementedError
 
 
 class BaseChatDownloader:
-    """
-    Subclasses of this should redefine the get_chat()
-    method and define a _VALID_URL regexp. The
-    _DEFAULT_FORMAT field may also be redefined.
-
-    Each chat item is a dictionary and must contain the following fields:
-
-    timestamp:          UNIX time (in microseconds) of when the message was sent.
-    message:            Actual content/text of the chat item.
-    message_id:         Identifier for the chat item.
-    message_type:       Message type of the item.
-    author:             A dictionary containing information about the user who
-                        sent the message.
-
-                        Mandatory fields:
-                        * name      The name of the author.
-                        * id        Idenfifier for the author.
-
-                        Optional fields:
-                        * display_name  The name of the author which is displayed
-                                    to the viewer. This may be different to `name`.
-                        * short_name    A shortened version of the author's name.
-                        * type      Type of the author.
-                        * url       URL for the author's channel/page.
-
-                        * images    A list of the author's profile picture in
-                                    different sizes. See below for the
-                                    fields which an image may have.
-                        * badges    A list of the author's badges.
-                                    Mandatory fields:
-                                    * title         The title of the badge.
-
-                                    Optional fields:
-                                    * id            Identifier for the badge.
-                                    * name          Name of the badge.
-                                    * version       Version of the badge.
-                                    * icon_name     Name of the badge icon.
-                                    * icons         A list of images for the badge icons.
-                                                    See below for potential fields.
-                                    * description   The description of the badge.
-                                    * alternative_title
-                                                    Alternative title of the badge.
-                                    * click_action  Action to perform if the badge is clicked.
-                                    * click_url     URL to visit if the badge is clicked.
-
-                        * gender    Gender of the author.
-
-                        The following boolean fields are self-explanatory:
-                        * is_banned
-                        * is_bot
-                        * is_non_coworker
-                        * is_original_poster
-                        * is_verified
-
-
-    Mandatory fields for replays/vods/clips (i.e. a video which is not live):
-
-    time_in_seconds:    The number of seconds after the video began, that the message was sent.
-    time_text:          Human-readable format for `time_in_seconds`.
-
-
-    Optional fields:
-
-    sub_message:        Additional text of the message.
-    action_type:        Action type of the item.
-    amount:             The amount of money which was sent with the message.
-    tooltip:            Text to be displayed when hovering over the message.
-    icon:               Icon associated with the message.
-    target_message_id:  The identifier for a message which this message references.
-    action:             The action of the message.
-    viewer_is_creator:  Whether the viewer is the creator or not.
-
-    sticker_images:     A list of the sticker image in different sizes. See
-                        below for the fields which an image may have.
-    sponsor_icons:      A list of the sponsor image in different sizes. See
-                        below for potential fields.
-    ticker_icons:       A list of the ticker image in different sizes. See
-                        below for potential fields.
-    ticker_duration:    How long the ticker message is displayed for.
-
-
-    The following fields indicate HEX colour information for the message:
-
-    author_name_text_colour
-    timestamp_colour
-    body_background_colour
-    header_text_colour
-    header_background_colour
-    body_text_colour
-    background_colour
-    money_chip_text_colour
-    money_chip_background_colour
-    start_background_colour
-    amount_text_colour
-    end_background_colour
-    detail_text_colour
-
-
-    An image contains the following fields:
-    url:                Mandatory. URL of the image.
-    id:                 Mandatory. Identifier for the image.
-    width:              Width of the image.
-    height:             Height of the image.
-
-
-
-    TODO
-    """
-
-    # id
-    # author_id
-    # author_name
-    # amount
-    # message
-    # time_text
-    # timestamp
-    # author_images
-    # tooltip
-    # icon
-    # author_badges
-    # badge_icons
-    # sticker_images
-    # ticker_duration
-    # sponsor_icons
-    # ticker_icons
-
-    # target_id
-    # action
-    # viewer_is_creator
-    # is_stackable
-    # sub_message
+    """Base class for chat downloaders. Each supported site should have its
+    own chat downloader. Subclasses should redefine the `_VALID_URLS`
+    dictionary which creates a mapping between functions and their matching
+    regular expressions. Optionally, subclasses should also redefine `_NAME`,
+    `_SITE_DEFAULT_PARAMS` and `_TESTS` fields."""
 
     _NAME = None
-    _VALID_URL = None
 
     _SITE_DEFAULT_PARAMS = {
         # MAY NOT specify message_types. must always be empty
@@ -213,7 +283,7 @@ class BaseChatDownloader:
             'params': {
                 'url': 'https://www.youtube.com/watch?v=5qap5aO4i9A',
                 'max_messages': 10,
-                'timeout': 60, # As a fallback
+                'timeout': 60,  # As a fallback
             },
 
             'expected_result': {
@@ -222,12 +292,8 @@ class BaseChatDownloader:
         }
     ]
 
-    _URL_GENERATORS = [
-
-    ]
-
     @staticmethod
-    def must_add_item(item, message_groups_dict, messages_groups_to_add, messages_types_to_add):
+    def _must_add_item(item, message_groups_dict, messages_groups_to_add, messages_types_to_add):
 
         # Force mutual exclusion
         if messages_types_to_add:
@@ -247,86 +313,38 @@ def must_add_item(item, message_groups_dict, messages_groups_to_add, messages_ty
         return item.get('message_type') in valid_message_types
 
     @staticmethod
-    def remap_dict(item, remapping_dict, keep_unknown_keys=False, replace_char_with_underscores=None):
-        info = {}
-        for key in item:
-            BaseChatDownloader.remap(
-                info, remapping_dict, key, item[key],
-                keep_unknown_keys=keep_unknown_keys,
-                replace_char_with_underscores=replace_char_with_underscores
-            )
-        return info
-
-    @staticmethod
-    def remap(info, remapping_dict, remap_key, remap_input, keep_unknown_keys=False, replace_char_with_underscores=None):
-        """
-        A function used to remap items from one dictionary to another
+    def _debug_log(params, *items):
+        """Method which simplifies the logging of debugging messages
 
-        :param info: Output dictionary
-        :param remapping_dict: Dictionary of remappings
-        :param remap_key: The key of the remapping
-        :param remap_input: The input sent to the remapping function
-        :param keep_unknown_keys: If no remap is found, keep the data with its original key and value
-        :param replace_char_with_underscores: If no remap is found, replace a character in the key with underscores
+        :param params: Dictionary of parameters sent to the `get_chat` method
+        :type params: dict
+        :raises UnexpectedError: if something unexpected occurs, but is only
+            used when debugging
         """
-        remap = remapping_dict.get(remap_key)
-
-        if remap:  # A matching 'remapping' has been found, apply this remapping
-            if isinstance(remap, Remapper):
-                new_key = remap.new_key  # or remap_key
-
-                # Perform transformation
-                if remap.remap_function:  # Has a remap function
-                    new_value = remap.remap_function(remap_input)
-                else:  # No remap function specified, apply identity transformation
-                    new_value = remap_input
-
-                # Assign values to info
-                if not remap.to_unpack:
-                    info[new_key] = new_value
-                elif isinstance(new_value, dict):
-                    info.update(new_value)
-                else:
-                    raise ValueError(
-                        'Unable to unpack item which is not a dictionary.')
-
-            elif isinstance(remap, str):
-                # If it is just a string, simply assign the new value to this key
-                info[remap] = remap_input
-            else:
-                raise ValueError('Unknown remapping specified.')
-
-        elif keep_unknown_keys:
-            if replace_char_with_underscores:
-                remap_key = remap_key.replace(
-                    replace_char_with_underscores, '_')
-            info[remap_key] = remap_input
-
-    @staticmethod
-    def debug_log(params, *items):
-        pause_on_debug = params.get('pause_on_debug')
-        exit_on_debug = params.get('exit_on_debug')
-
         log(
             'debug',
             items,
-            pause_on_debug
+            params.get('pause_on_debug')
         )
-        if exit_on_debug:
+        if params.get('exit_on_debug'):
             raise UnexpectedError(items)
 
     def __init__(self,
                  **kwargs
                  ):
+        """Initialise a session with various parameters
 
-        # Begin session
+        :raises CookieError: if unable to read or parse the cookie file
+        """
+
+        # Start a new session
         self.session = requests.Session()
 
         headers = kwargs.get('headers')
         if headers is None:
             headers = {
                 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36',
-                'Accept-Language': 'en-US, en'
+                'Accept-Language': 'en-US, en, *'  # 'de-CH'#'fr-CH'#
             }
         self.session.headers = headers
 
@@ -360,73 +378,101 @@ def update_session_headers(self, new_headers):
         self.session.headers.update(new_headers)
 
     def clear_cookies(self):
+        """Clear the session's cookies."""
         self.session.cookies.clear()
 
-    def get_cookies_dict(self):
+    def _get_cookies_dict(self):
+        """Returns a key/value dictionary from the session's CookieJar
+
+        :return: Dictionary of cookies
+        :rtype: dict
+        """
         return requests.utils.dict_from_cookiejar(self.session.cookies)
 
     def get_cookie_value(self, name, default=None):
-        return self.get_cookies_dict().get(name, default)
+        """Return the value for key if key is in the cookie dictionary, else default.
+
+        :param name: The key of the cookie
+        :type name: str
+        :param default: Return this value if the specified cookie cannot be found, defaults to None
+        :type default: object, optional
+        :return: The cookie value, or default
+        :rtype: Union[str, object, None]
+        """
+        return self._get_cookies_dict().get(name, default)
 
     def close(self):
+        """Close the session. Once this has been called, no more requests can be made."""
         self.session.close()
         log('debug', 'Session closed.')
 
-    # def __enter__(self):
-    #     return self
-
-    # def __exit__(self, exc_type, exc_val, exc_tb):
-    #     pass
-    #     # self.close()
-
     def _session_post(self, url, **kwargs):
-        """Make a request using the current session."""
+        """Make a post request using the current session."""
         return self.session.post(url, **kwargs)
 
     def _session_get(self, url, **kwargs):
-        """Make a request using the current session."""
+        """Make a get request using the current session."""
         return self.session.get(url, **kwargs)
 
     def _session_get_json(self, url, **kwargs):
-        """Make a request using the current session and get json data."""
+        """Make a get request using the current session and return as JSON."""
         return self._session_get(url, **kwargs).json()
 
-    def get_site_value(self, v):
-        if isinstance(v, SiteDefault):
+    def get_site_value(self, value):
+        """Get the site's default value for a certain parameter
+
+        :param value: The value
+        :type value: Union[SiteDefault, object]
+        :return: The site's default value
+        :rtype: object
+        """
+        if isinstance(value, SiteDefault):
             return self._SITE_DEFAULT_PARAMS.get(
-                v.name, BaseChatDownloader._SITE_DEFAULT_PARAMS.get(v.name))
+                value.name, BaseChatDownloader._SITE_DEFAULT_PARAMS.get(value.name))
         else:
-            return v
+            return value
+
+    _VALID_URLS = {
+        # function_name: regex
+    }
+
+    @classmethod
+    def matches(cls, url):
+        """Used to check if a url matches any of the
+        regular expressions specified in the classes
+        `_VALID_URLS` dictionary.
+
+        :return: If a match is found, the function name and
+            match object is returned, otherwise None.
+        :rtype: (str, re.Match)
+        """
+        for function_name, regex in cls._VALID_URLS.items():
+
+            if isinstance(regex, str):
+                match = re.search(regex, url)
+                if match:
+                    return function_name, match
+
+        return None
 
     def get_chat(self, **kwargs):
-        raise NotImplementedError
+        """This method should be implemented in a subclass and should return
+        the appropriate `Chat` object with respect to the specified parameters.
 
-    @staticmethod
-    def generate_urls(**kwargs):
+        :raises NotImplementedError: if not implemented and called from a subclass
+        """
         raise NotImplementedError
 
-    @staticmethod
-    def create_image(url, width=None, height=None, image_id=None):
-        if url.startswith('//'):
-            url = 'https:' + url
-        image = {
-            'url': url,
-        }
-        if width:
-            image['width'] = width
-        if height:
-            image['height'] = height
-
-        # TODO remove id?
-        if width and height and not image_id:
-            image['id'] = '{}x{}'.format(width, height)
-        elif image_id:
-            image['id'] = image_id
+    def generate_urls(self, **kwargs):
+        """This method should be implemented in a subclass and should return
+        a generator which yields URLs for testing.
 
-        return image
+        :raises NotImplementedError: if not implemented and called from a subclass
+        """
+        raise NotImplementedError
 
     @staticmethod
-    def move_to_dict(info, dict_name, replace_key=None, create_when_empty=False, *info_keys):
+    def _move_to_dict(info, dict_name, replace_key=None, create_when_empty=False, *info_keys):
         """
         Move all items with keys that contain some text to a separate dictionary.
 
@@ -455,6 +501,21 @@ def move_to_dict(info, dict_name, replace_key=None, create_when_empty=False, *in
 
     @staticmethod
     def retry(attempt_number, max_attempts, error, retry_timeout=None, text=None):
+        """Retry to occur after an error occurs
+
+        :param attempt_number: The current attempt number
+        :type attempt_number: int
+        :param max_attempts: The maximum number of attempts allowed
+        :type max_attempts: int
+        :param error: The error which was raised
+        :type error: Exception
+        :param retry_timeout: The number of seconds to sleep after failing,
+            defaults to None (i.e. use exponential back-off)
+        :type retry_timeout: float, optional
+        :param text: Items to display on retry, defaults to None
+        :type text: object, optional
+        :raises RetriesExceeded: if the maximum number of retries has been exceeded
+        """
         if attempt_number >= max_attempts:
             raise RetriesExceeded(
                 'Maximum number of retries has been reached ({}).'.format(max_attempts))
@@ -506,6 +567,14 @@ def retry(attempt_number, max_attempts, error, retry_timeout=None, text=None):
 
     @staticmethod
     def check_for_invalid_types(messages_types_to_add, allowed_message_types):
+        """Used to check for invalid message types
+
+        :param messages_types_to_add: List of message types to add
+        :type messages_types_to_add: list
+        :param allowed_message_types: List of allowed message type
+        :type allowed_message_types: list
+        :raises InvalidParameter: if invalid types are specified
+        """
         invalid_types = set(messages_types_to_add) - set(allowed_message_types)
         if invalid_types:
             raise InvalidParameter(
diff --git a/chat_downloader/sites/facebook.py b/chat_downloader/sites/facebook.py
index f731405..988e229 100644
--- a/chat_downloader/sites/facebook.py
+++ b/chat_downloader/sites/facebook.py
@@ -1,30 +1,29 @@
-import json
-from json.decoder import JSONDecodeError
-import xml.etree.ElementTree as ET
-import isodate
-import re
-
 from .common import (
     Chat,
     BaseChatDownloader,
-    Remapper as r
+    Remapper as r,
+    Image
 )
-
-from requests.exceptions import RequestException
-
 from ..utils import (
     remove_prefixes,
     multi_get,
     try_get_first_value,
-    try_get,
     seconds_to_time,
     camel_case_split,
     ensure_seconds,
     attempts,
     get_title_of_webpage,
-    log
 )
 
+from ..debugging import log
+
+import json
+import re
+import xml.etree.ElementTree as ET
+import isodate
+from json.decoder import JSONDecodeError
+from requests.exceptions import RequestException
+
 
 class FacebookChatDownloader(BaseChatDownloader):
     _FB_HOMEPAGE = 'https://www.facebook.com'
@@ -84,7 +83,9 @@ def __init__(self, **kwargs):
 
     _NAME = 'facebook.com'
     # Regex provided by youtube-dl
-    _VALID_URL = r'''(?x)
+
+    _VALID_URLS = {
+        '_get_chat_by_video_id': r'''(?x)
             (?:
                 https?://
                     (?:[\w-]+\.)?(?:facebook\.com)/
@@ -93,6 +94,7 @@ def __init__(self, **kwargs):
             )
             (?P<id>[0-9]+)
             '''
+    }
 
     _TESTS = [
 
@@ -168,7 +170,7 @@ def _get_initial_info(self, video_id, params):
 
         video_data = {}
         for item in instances:
-            if try_get(item, lambda x: x[1][0]) == 'VideoConfig':
+            if multi_get(item, 1, 0) == 'VideoConfig':
                 video_item = item[2][0]
                 if video_item.get('video_id'):
                     video_data = video_item['videoData'][0]
@@ -218,15 +220,15 @@ def _parse_feedback(feedback):
         return new_feedback
 
     @staticmethod
-    def get_text(item):
+    def _get_text(item):
         return item.get('text') if item else None
 
     @staticmethod
-    def parse_image(item):
-        return BaseChatDownloader.create_image(item.get('uri'), item.get('width'), item.get('height'))
+    def _parse_image(item):
+        return Image(item.get('uri'), item.get('width'), item.get('height')).json()
 
     @staticmethod
-    def get_uri(item):
+    def _get_uri(item):
         return item.get('uri')
 
     @staticmethod
@@ -240,8 +242,8 @@ def _parse_attachment_info(original_item):
             return item
 
         for key in original_item:
-            BaseChatDownloader.remap(
-                item, FacebookChatDownloader._TARGET_MEDIA_REMAPPING, key, original_item[key])
+            r.remap(item, FacebookChatDownloader._TARGET_MEDIA_REMAPPING,
+                    key, original_item[key])
 
         # VideoTipJarPayment
         quantity = item.get('quantity')
@@ -254,20 +256,16 @@ def _parse_attachment_info(original_item):
         massive_image = item.pop('massive_image', None)
 
         if blurred_image and massive_image:
-            item['text'] = BaseChatDownloader.create_image(
-                blurred_image,
-                massive_image.get('width'),
-                massive_image.get('height')
-            )
+            item['text'] = Image(blurred_image, massive_image.get(
+                'width'), massive_image.get('height')).json()
 
         # style_infos
         donation_comment_text = item.pop('donation_comment_text', None)
         if donation_comment_text:
-            entity = try_get(donation_comment_text,
-                             lambda x: x['ranges'][0]['entity']) or {}
+            entity = multi_get(donation_comment_text, 'ranges', 0, 'entity') or {}
 
             for key in entity:
-                BaseChatDownloader.remap(
+                r.remap(
                     item, FacebookChatDownloader._TARGET_MEDIA_REMAPPING, key, entity[key])
             item['text'] = donation_comment_text.get('text')
 
@@ -292,10 +290,11 @@ def _parse_target(media):
     def _parse_author_badges(item):
 
         keys = (('badge_asset', 'small'), ('information_asset', 'colour'))
-        icons = list(map(lambda x: BaseChatDownloader.create_image(
-            FacebookChatDownloader._FB_HOMEPAGE + item.get(x[0]), 24, 24, x[1]), keys))
-        icons.append(BaseChatDownloader.create_image(
-            item.get('multiple_badge_asset'), 36, 36, 'large'))
+        icons = list(map(lambda x: Image(
+            FacebookChatDownloader._FB_HOMEPAGE + item.get(x[0]), 24, 24, x[1]).json(), keys))
+
+        icons.append(
+            Image(item.get('multiple_badge_asset'), 36, 36, 'large').json())
 
         return {
             'title': item.get('text'),
@@ -313,14 +312,14 @@ def _parse_author_badges(item):
 
     _ATTACHMENT_REMAPPING = {
         'url': 'url',  # facebook redirect url,
-        'source': r('source', get_text),
-        'title_with_entities': r('title', get_text),
+        'source': r('source', _get_text),
+        'title_with_entities': r('title', _get_text),
 
         'target': r('target', _parse_attachment_info),
         'media': r('media', _parse_attachment_info),
         'style_infos': r('style_infos', _parse_attachment_info),
 
-        'attachment_text': r('text', get_text),
+        'attachment_text': r('text', _get_text),
     }
 
     _IGNORE_ATTACHMENT_KEYS = [
@@ -343,8 +342,8 @@ def _parse_attachment_styles(item):
 
         # set texts:
         for key in attachment:
-            BaseChatDownloader.remap(
-                parsed, FacebookChatDownloader._ATTACHMENT_REMAPPING, key, attachment[key])
+            r.remap(parsed, FacebookChatDownloader._ATTACHMENT_REMAPPING,
+                    key, attachment[key])
 
         for key in ('target', 'media', 'style_infos'):
             if parsed.get(key) == {}:
@@ -362,7 +361,7 @@ def _parse_attachment_styles(item):
     _TARGET_MEDIA_REMAPPING = {
         'id': 'id',
         '__typename': r('type', camel_case_split),
-        'fallback_image': r('image', parse_image),
+        'fallback_image': r('image', _parse_image),
         'is_playable': 'is_playable',
         'url': 'url',
 
@@ -372,7 +371,7 @@ def _parse_attachment_styles(item):
         # Sticker
         'pack': 'pack',
         'label': 'label',
-        'image': r('image', parse_image),
+        'image': r('image', _parse_image),
 
         # VideoTipJarPayment
 
@@ -387,12 +386,12 @@ def _parse_attachment_styles(item):
         'address': 'address',
         'overall_star_rating': 'overall_star_rating',
 
-        'profile_picture': r('profile_picture', get_uri),
+        'profile_picture': r('profile_picture', _get_uri),
 
         # Photo
         'accessibility_caption': 'accessibility_caption',
 
-        'blurred_image': r('blurred_image', get_uri),
+        'blurred_image': r('blurred_image', _get_uri),
         'massive_image': 'massive_image',
 
 
@@ -455,7 +454,7 @@ def _parse_attachment_styles(item):
 
         'url': 'message_url',
 
-        'body': r('message', get_text),
+        'body': r('message', _get_text),
 
         'identity_badges_web': r('author_badges', lambda x: list(map(FacebookChatDownloader._parse_author_badges, x))),
 
@@ -482,14 +481,14 @@ def _parse_live_stream_node(node):
         info = {}
 
         for key in node:
-            BaseChatDownloader.remap(
-                info, FacebookChatDownloader._REMAPPING, key, node[key])
+            r.remap(info, FacebookChatDownloader._REMAPPING, key, node[key])
 
         author_info = info.pop('author', {})
-        BaseChatDownloader.move_to_dict(info, 'author', create_when_empty=True)
+        BaseChatDownloader._move_to_dict(
+            info, 'author', create_when_empty=True)
 
         for key in author_info:
-            BaseChatDownloader.remap(
+            r.remap(
                 info['author'], FacebookChatDownloader._AUTHOR_REMAPPING, key, author_info[key])
 
         if 'profile_picture_depth_0' in author_info:
@@ -498,7 +497,7 @@ def _parse_live_stream_node(node):
                 url = multi_get(
                     author_info, 'profile_picture_depth_{}'.format(size[0]), 'uri')
                 info['author']['images'].append(
-                    BaseChatDownloader.create_image(url, size[1], size[1]))
+                    Image(url, size[1], size[1]).json())
 
         # author_badges = info.pop('author_badges', None)
         # if author_badges:
@@ -698,7 +697,7 @@ def _get_chat_replay_messages_by_video_id(self, video_id, max_duration, params):
                     continue
 
                 # ['comments'][0]['body']['text']
-                comment = try_get(ufipayload, lambda x: x['comments'][0])
+                comment = multi_get(ufipayload, 'comments', 0)
                 if not comment:
                     # TODO debug
                     continue
@@ -718,6 +717,8 @@ def _get_chat_replay_messages_by_video_id(self, video_id, max_duration, params):
                 }
 
                 yield temp
+    def _get_chat_by_video_id(self, match, params):
+        return self.get_chat_by_video_id(match.group('id'), params)
 
     def get_chat_by_video_id(self, video_id, params):
 
@@ -747,16 +748,3 @@ def get_chat_by_video_id(self, video_id, params):
             is_live=is_live,
             author=initial_info.get('author'),
         )
-
-    def get_chat(self, **kwargs):
-
-        url = kwargs.get('url')
-        match = re.search(self._VALID_URL, url)
-
-        if match:
-
-            if match.group('id'):  # normal youtube video
-                return self.get_chat_by_video_id(match.group('id'), kwargs)
-
-            else:  # TODO add profile, etc.
-                pass
diff --git a/chat_downloader/sites/twitch.py b/chat_downloader/sites/twitch.py
index a48ab1c..99894dd 100644
--- a/chat_downloader/sites/twitch.py
+++ b/chat_downloader/sites/twitch.py
@@ -1,38 +1,40 @@
-import re
-import json
-import time
-import socket
-import base64
-
 from .common import (
     Chat,
     BaseChatDownloader,
-    Remapper as r
+    Remapper as r,
+    Image
 )
 
-from requests.exceptions import RequestException
-from json.decoder import JSONDecodeError
-
-
 from ..errors import (
     TwitchError,
     NoChatReplay,
-    VideoUnavailable
+    VideoUnavailable,
+    UserNotFound
 )
 
 from ..utils import (
     ensure_seconds,
     timestamp_to_microseconds,
     seconds_to_time,
-    try_get,
     int_or_none,
     replace_with_underscores,
     multi_get,
-    log,
     remove_prefixes,
     attempts
 )
 
+from ..debugging import log
+
+import re
+import json
+import time
+import socket
+import base64
+
+from requests.exceptions import RequestException
+from json.decoder import JSONDecodeError
+
+
 # TODO export as another module?
 
 
@@ -179,30 +181,29 @@ def __init__(self, **kwargs):
         'format': 'twitch',
     }
 
-    _VALID_URL = r'https?://(?:(?:www|go|m|clips)\.)?twitch\.tv'
-
-    # e.g. 'http://www.twitch.tv/riotgames/v/6528877?t=5m10s'
-    _VALID_VOD_URL = r'''(?x)
+    _VALID_URLS = {
+        # e.g. 'http://www.twitch.tv/riotgames/v/6528877?t=5m10s'
+        '_get_chat_by_vod_id': r'''(?x)
                     https?://
                         (?:
                             (?:(?:www|go|m)\.)?twitch\.tv/(?:[^/]+/v(?:ideo)?|videos)/|
                             player\.twitch\.tv/\?.*?\bvideo=v?
                         )
                         (?P<id>\d+)
-                    '''
+                    ''',
 
-    # e.g. 'https://clips.twitch.tv/FaintLightGullWholeWheat'
-    _VALID_CLIPS_URL = r'''(?x)
+        # e.g. 'https://clips.twitch.tv/FaintLightGullWholeWheat'
+        '_get_chat_by_clip_id': r'''(?x)
                         https?://
                             (?:
                                 clips\.twitch\.tv/(?:embed\?.*?\bclip=|(?:[^/]+/)*)|
                                 (?:(?:www|go|m)\.)?twitch\.tv/[^/]+/clip/
                             )
                             (?P<id>[^/?#&]+)
-                        '''
+                        ''',
 
-    # e.g. 'http://www.twitch.tv/shroomztv'
-    _VALID_STREAM_URL = r'''(?x)
+        # e.g. 'http://www.twitch.tv/shroomztv'
+        '_get_chat_by_stream_id': r'''(?x)
                         https?://
                             (?:
                                 (?:(?:www|go|m)\.)?twitch\.tv/|
@@ -210,6 +211,7 @@ def __init__(self, **kwargs):
                             )
                             (?P<id>[^/#?]+)
                         '''
+    }
 
     _CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'  # public client id
 
@@ -227,32 +229,32 @@ def __init__(self, **kwargs):
     }
 
     @staticmethod
-    def parse_bool(text):
+    def _parse_bool(text):
         return text == '1'
 
     @staticmethod
-    def parse_bool_text(text):
+    def _parse_bool_text(text):
         return text == 'true'
 
     @staticmethod
-    def parse_author_images(original_url):
+    def _parse_author_images(original_url):
         # e.g. https://static-cdn.jtvnw.net/jtv_user_pictures/3892c956-0616-4fc9-b2fe-527b1be0b623-profile_image-300x300.png
         smaller_icon = original_url.replace('300x300', '70x70')
         return [
-            BaseChatDownloader.create_image(original_url, 300, 300),
-            BaseChatDownloader.create_image(smaller_icon, 70, 70),
+            Image(original_url, 300, 300).json(),
+            Image(smaller_icon, 70, 70).json(),
         ]
 
     @staticmethod
-    def parse_commenter(commenter):
+    def _parse_commenter(commenter):
         info = {}
         for key in commenter or []:
-            BaseChatDownloader.remap(
-                info, TwitchChatDownloader._AUTHOR_REMAPPING, key, commenter[key])
+            r.remap(info, TwitchChatDownloader._AUTHOR_REMAPPING,
+                    key, commenter[key])
         return info
 
     @staticmethod
-    def parse_message_info(message):
+    def _parse_message_info(message):
         # Ignore: fragments, is_action
         message_text = message.get('body') or ''
 
@@ -268,7 +270,7 @@ def parse_message_info(message):
                 if emote_id not in message_emotes:
                     message_emotes[emote_id] = {
                         'id': emote_id,
-                        'images': TwitchChatDownloader.generate_twitch_emote_image_list(emote_id),
+                        'images': TwitchChatDownloader._generate_emote_image_list(emote_id),
                         'name': message_text[begin:end + 1]
                     }
 
@@ -296,24 +298,24 @@ def parse_message_info(message):
         return message_info
 
     @staticmethod
-    def decode_pseudo_BNF(text):
+    def _decode_pseudo_BNF(text):
         """
         Decode text according to https://ircv3.net/specs/extensions/message-tags.html
         """
         return text.replace(r'\:', ';').replace(r'\s', ' ')
 
     @staticmethod
-    def generate_twitch_emote_image_list(emote_id):
+    def _generate_emote_image_list(emote_id):
         emote_image_list = []
         for theme in ('light', 'dark'):
             for size in ((28, '1.0'), (56, '2.0'), (112, '3.0')):
-                image = BaseChatDownloader.create_image(
+                image = Image(
                     TwitchChatDownloader._EMOTE_URL_TEMPLATE.format(
                         emote_id, theme, size[1]),
                     size[0],
                     size[0],
                     '{0}x{0}-{1}'.format(size[0], theme)
-                )
+                ).json()
 
                 emote_image_list.append(image)
         return emote_image_list
@@ -322,7 +324,7 @@ def generate_twitch_emote_image_list(emote_id):
     _EMOTE_URL_TEMPLATE = 'https://static-cdn.jtvnw.net/emoticons/v2/{}/default/{}/{}'
 
     @staticmethod
-    def parse_emotes(text):
+    def _parse_emotes(text):
         # Information to replace text in the message with emote images. This can be empty.
         # <emote ID>:<first index>-<last index>,<another first index>-<another last index>/<another emote ID>:<first index>-<last index>
         emotes = []
@@ -334,7 +336,7 @@ def parse_emotes(text):
             emote = {
                 'id': emote_id,
                 'locations': match[1].split(','),
-                'images': TwitchChatDownloader.generate_twitch_emote_image_list(emote_id)
+                'images': TwitchChatDownloader._generate_emote_image_list(emote_id)
             }
             emotes.append(emote)
 
@@ -344,7 +346,7 @@ def parse_emotes(text):
         '_id': r('id', int_or_none),
         'name': 'name',
         'display_name': 'display_name',
-        'logo': r('images', parse_author_images),
+        'logo': r('images', _parse_author_images),
         'type': 'type',
         'created_at': r('created_at', timestamp_to_microseconds),
         # 'updated_at': r('updated_at', 'parse_timestamp'),
@@ -354,14 +356,14 @@ def parse_emotes(text):
     _COMMENT_REMAPPING = {
         '_id': 'message_id',
         'created_at': r('timestamp', timestamp_to_microseconds),
-        'commenter': r('author', parse_commenter),
+        'commenter': r('author', _parse_commenter),
 
         'content_offset_seconds': 'time_in_seconds',
 
         'source': 'source',
         'state': 'state',
         # TODO make sure body vs. fragments okay
-        'message': r(None, parse_message_info, True)
+        'message': r(None, _parse_message_info, True)
     }
 
     _MESSAGE_PARAM_REMAPPING = {
@@ -385,10 +387,11 @@ def parse_emotes(text):
         'msg-param-sender-login': 'gifter_name',
         'msg-param-sender-name': 'gifter_display_name',
 
-        'msg-param-should-share-streak': r('user_wants_to_share_streaks', parse_bool),
+        'msg-param-should-share-streak': r('user_wants_to_share_streaks', _parse_bool),
         'msg-param-streak-months': r('number_of_consecutive_months_subscribed', int_or_none),
         'msg-param-sub-plan': r('subscription_type', lambda x: TwitchChatDownloader._SUBSCRIPTION_TYPES.get(x)),
-        'msg-param-sub-plan-name': r('subscription_plan_name', decode_pseudo_BNF),
+        'msg-param-sub-plan-name': r('subscription_plan_name', _decode_pseudo_BNF),
+        'msg-param-sub-benefit-end-month': r('sub_benefit_end_month', int_or_none),
 
         'msg-param-ritual-name': 'ritual_name',
 
@@ -400,12 +403,12 @@ def parse_emotes(text):
         # resub
         'msg-param-multimonth-duration': r('multimonth_duration', int_or_none),
         'msg-param-multimonth-tenure': r('multimonth_tenure', int_or_none),
-        'msg-param-was-gifted': r('was_gifted', parse_bool_text),
+        'msg-param-was-gifted': r('was_gifted', _parse_bool_text),
 
         'msg-param-gifter-id': 'gifter_id',
         'msg-param-gifter-login': 'gifter_name',
         'msg-param-gifter-name': 'gifter_display_name',
-        'msg-param-anon-gift': r('was_anonymous_gift', parse_bool_text),
+        'msg-param-anon-gift': r('was_anonymous_gift', _parse_bool_text),
         'msg-param-gift-month-being-redeemed': r('gift_months_being_redeemed', int_or_none),
 
         # rewardgift
@@ -416,12 +419,12 @@ def parse_emotes(text):
         'msg-param-trigger-amount': r('trigger_amount', int_or_none),
 
         # submysterygift
-        'msg-param-origin-id': r('origin_id', decode_pseudo_BNF),
+        'msg-param-origin-id': r('origin_id', _decode_pseudo_BNF),
         'msg-param-sender-count': r('sender_count', int_or_none),
         'msg-param-mass-gift-count': r('mass_gift_count', int_or_none),
 
         # communitypayforward
-        'msg-param-prior-gifter-anonymous': r('prior_gifter_anonymous', parse_bool_text),
+        'msg-param-prior-gifter-anonymous': r('prior_gifter_anonymous', _parse_bool_text),
         'msg-param-prior-gifter-user-name': 'prior_gifter_name',
         'msg-param-prior-gifter-display-name': 'prior_gifter_display_name',
         'msg-param-prior-gifter-id': 'prior_gifter_id',
@@ -498,13 +501,13 @@ def parse_emotes(text):
         'bits': r('bits', int_or_none),
 
         'id': 'message_id',
-        'mod': r('author_is_moderator', parse_bool),
+        'mod': r('author_is_moderator', _parse_bool),
         'room-id': r('channel_id', int_or_none),
 
         'tmi-sent-ts': r('timestamp', lambda x: int_or_none(x, 0) * 1000),
 
-        'subscriber': r('author_is_subscriber', parse_bool),
-        'turbo': r('author_is_turbo', parse_bool),
+        'subscriber': r('author_is_subscriber', _parse_bool),
+        'turbo': r('author_is_turbo', _parse_bool),
 
         'client-nonce': 'client_nonce',
 
@@ -512,7 +515,7 @@ def parse_emotes(text):
 
 
 
-        'reply-parent-msg-body': r('in_reply_to_message', decode_pseudo_BNF),
+        'reply-parent-msg-body': r('in_reply_to_message', _decode_pseudo_BNF),
         'reply-parent-user-id': r('in_reply_to_author_id', int_or_none),
         'reply-parent-msg-id': 'in_reply_to_message_id',
         'reply-parent-display-name': 'in_reply_to_author_display_name',
@@ -522,22 +525,22 @@ def parse_emotes(text):
         'custom-reward-id': 'custom_reward_id',
 
 
-        'emotes': r('emotes', parse_emotes),
+        'emotes': r('emotes', _parse_emotes),
         'flags': 'flags',
 
 
 
         # ROOMSTATE
-        'emote-only': r('emote_only', parse_bool),
+        'emote-only': r('emote_only', _parse_bool),
         'followers-only': r('follower_only', int_or_none),
 
-        'r9k': r('r9k_mode', parse_bool),
+        'r9k': r('r9k_mode', _parse_bool),
         'slow': r('slow_mode', int_or_none),
-        'subs-only': r('subscriber_only', parse_bool),
-        'rituals': r('rituals_enabled', parse_bool),
+        'subs-only': r('subscriber_only', _parse_bool),
+        'rituals': r('rituals_enabled', _parse_bool),
 
         # USERNOTICE
-        'system-msg': r('system_message', decode_pseudo_BNF),
+        'system-msg': r('system_message', _decode_pseudo_BNF),
 
         # (Commands)
         # HOSTTARGET
@@ -770,13 +773,13 @@ def parse_emotes(text):
     }
 
     _MESSAGE_TYPE_REMAPPING = {}
-    for message_group in _MESSAGE_GROUP_REMAPPINGS:
-        value = _MESSAGE_GROUP_REMAPPINGS[message_group]
-        _MESSAGE_TYPE_REMAPPING.update(value)
+    for _message_group in _MESSAGE_GROUP_REMAPPINGS:
+        _value = _MESSAGE_GROUP_REMAPPINGS[_message_group]
+        _MESSAGE_TYPE_REMAPPING.update(_value)
 
-        if message_group not in _MESSAGE_GROUPS:
-            _MESSAGE_GROUPS[message_group] = []
-        _MESSAGE_GROUPS[message_group] += list(value.values())
+        if _message_group not in _MESSAGE_GROUPS:
+            _MESSAGE_GROUPS[_message_group] = []
+        _MESSAGE_GROUPS[_message_group] += list(_value.values())
 
     _SUBSCRIBER_BADGE_INFO = {}  # local cache for subscriber badge info
     _SUBSCRIBER_BADGE_URL = 'https://badges.twitch.tv/v1/badges/channels/{}/display'
@@ -798,8 +801,8 @@ def _update_subscriber_badge_info(self, channel_id):
     def _parse_item(item, offset):
         info = {}
         for key in item:
-            BaseChatDownloader.remap(
-                info, TwitchChatDownloader._COMMENT_REMAPPING, key, item[key])  # , True
+            r.remap(info, TwitchChatDownloader._COMMENT_REMAPPING,
+                    key, item[key])  # , True
 
         if 'time_in_seconds' in info:
             info['time_in_seconds'] -= offset
@@ -811,21 +814,21 @@ def _parse_item(item, offset):
 
         badges = info.pop('author_badges', None)
         if badges:
-            info['author']['badges'] = list(map(lambda x: TwitchChatDownloader.parse_badge_info(
+            info['author']['badges'] = list(map(lambda x: TwitchChatDownloader._parse_badge_info(
                 x.get('_id'), x.get('version'), channel_id), badges))
 
         user_notice_params = info.pop('user_notice_params', {})
 
         for key in user_notice_params:
-            BaseChatDownloader.remap(
-                info, TwitchChatDownloader._MESSAGE_PARAM_REMAPPING, key, user_notice_params[key], True)
+            r.remap(info, TwitchChatDownloader._MESSAGE_PARAM_REMAPPING,
+                    key, user_notice_params[key], True)
 
         # TODO add user colour to author dict
         # TODO check this works
         # author_colour
         # print()
 
-        BaseChatDownloader.move_to_dict(info, 'author')
+        BaseChatDownloader._move_to_dict(info, 'author')
 
         original_message_type = info.get('message_type')
         if original_message_type:
@@ -878,9 +881,9 @@ def _download_gql(self, ops):
     }
 
     @staticmethod
-    def parse_user(item):
+    def _parse_user(item):
         if isinstance(item, dict):
-            return BaseChatDownloader.remap_dict(item, TwitchChatDownloader._USER_REMAPPING)
+            return r.remap_dict(item, TwitchChatDownloader._USER_REMAPPING)
         return None
 
     _GAME_REMAPPING = {
@@ -891,9 +894,9 @@ def parse_user(item):
     }
 
     @staticmethod
-    def parse_game(item):
+    def _parse_game(item):
         if isinstance(item, dict):
-            return BaseChatDownloader.remap_dict(item, TwitchChatDownloader._GAME_REMAPPING)
+            return r.remap_dict(item, TwitchChatDownloader._GAME_REMAPPING)
         return None
 
     _CLIP_REMAPPING = {
@@ -904,10 +907,10 @@ def parse_game(item):
         'title': 'title',
         'viewCount': 'views',
         'language': 'language',
-        'curator': r('curator', parse_user),
-        'game': r('game', parse_game),
+        'curator': r('curator', _parse_user),
+        'game': r('game', _parse_game),
         'language': 'language',
-        'broadcaster': r('broadcaster', parse_user),
+        'broadcaster': r('broadcaster', _parse_user),
 
         'thumbnailURL': 'thumbnail_url',
         'createdAt': r('created_at', timestamp_to_microseconds),
@@ -954,7 +957,7 @@ def get_user_clips(self, username, limit=100, filter_by='LAST_WEEK'):
 
             for edge in edges:
                 node = edge['node'] or {}
-                yield BaseChatDownloader.remap_dict(node, TwitchChatDownloader._CLIP_REMAPPING)
+                yield r.remap_dict(node, TwitchChatDownloader._CLIP_REMAPPING)
 
             if not clips['pageInfo']['hasNextPage']:
                 break
@@ -962,11 +965,11 @@ def get_user_clips(self, username, limit=100, filter_by='LAST_WEEK'):
     _VIDEO_REMAPPING = {
         'id': r('id', int_or_none),
         'animatedPreviewURL': 'animated_preview_url',
-        'game': r('game', parse_game),
+        'game': r('game', _parse_game),
 
         'lengthSeconds': 'duration',
 
-        'owner': r('owner', parse_user),
+        'owner': r('owner', _parse_user),
 
         'previewThumbnailURL': 'preview_thumbnail_url',
 
@@ -1023,7 +1026,7 @@ def get_user_videos(self, username, limit=30, video_type=None, sort='TIME'):
 
             for edge in edges:
                 node = edge['node'] or {}
-                yield BaseChatDownloader.remap_dict(node, TwitchChatDownloader._VIDEO_REMAPPING)
+                yield r.remap_dict(node, TwitchChatDownloader._VIDEO_REMAPPING)
 
             if not videos['pageInfo']['hasNextPage']:
                 break
@@ -1048,8 +1051,8 @@ def get_featured_videos(self, username):
         'viewersCount': 'viewers',
 
         'previewImageURL': 'preview_image_url',
-        'broadcaster': r('broadcaster', parse_user),
-        'game': r('game', parse_game),
+        'broadcaster': r('broadcaster', _parse_user),
+        'game': r('game', _parse_game),
 
 
         # 'tags': 'tags'
@@ -1087,41 +1090,33 @@ def get_top_livestreams(self, limit=30):
 
             for edge in edges:
                 node = edge['node'] or {}
-                yield BaseChatDownloader.remap_dict(node, TwitchChatDownloader._LIVESTREAM_REMAPPING)
+                yield r.remap_dict(node, TwitchChatDownloader._LIVESTREAM_REMAPPING)
 
     _TWITCH_HOME = 'https://www.twitch.tv'
     _TWITCH_VIDEOS = 'https://www.twitch.tv/videos'
 
-    @staticmethod
-    def generate_urls(livestream_limit = 10, vod_limit = 5, clip_limit = 5, **kwargs):
-        downloader = TwitchChatDownloader()
-
+    def generate_urls(self, livestream_limit=10, vod_limit=5, clip_limit=5, **kwargs):
         # max_tests = livestream_limit + livestream_limit*(vod_limit+clip_limit)
 
-        livestreams = downloader.get_top_livestreams(livestream_limit)
+        livestreams = self.get_top_livestreams(livestream_limit)
         for livestream in livestreams:
             name = livestream['broadcaster']['name']
 
             # e.g. https://www.twitch.tv/shroud
-            yield '{}/{}'.format(TwitchChatDownloader._TWITCH_HOME, name)
+            yield '{}/{}'.format(self._TWITCH_HOME, name)
 
-            vods = downloader.get_user_videos(name, vod_limit)
+            vods = self.get_user_videos(name, vod_limit)
             for vod in vods:
                 # e.g. https://www.twitch.tv/videos/12345678
-                yield '{}/{}'.format(TwitchChatDownloader._TWITCH_VIDEOS, vod['id'])
+                yield '{}/{}'.format(self._TWITCH_VIDEOS, vod['id'])
 
-            clips = downloader.get_user_clips(name, clip_limit)
+            clips = self.get_user_clips(name, clip_limit)
             for clip in clips:
                 # e.g. https://clips.twitch.tv/FastThankfulLobsterEagleEye-SFi4SJWaTkAYu-B3
                 yield clip['url']
 
-    _REGEX_FUNCTION_MAP = [
-        (_VALID_VOD_URL, 'get_chat_by_vod_id'),
-        (_VALID_CLIPS_URL, 'get_chat_by_clip_id'),
-        (_VALID_STREAM_URL, 'get_chat_by_stream_id'),
-    ]
-
     # offset and max_duration are used by clips
+
     def _get_chat_messages_by_vod_id(self, vod_id, params, max_duration, offset=None):
 
         # twitch does not provide messages before the stream starts,
@@ -1178,14 +1173,14 @@ def _get_chat_messages_by_vod_id(self, vod_id, params, max_duration, offset=None
                 missing_keys = data.keys() - TwitchChatDownloader._KNOWN_COMMENT_KEYS
 
                 if missing_keys:
-                    self.debug_log(params,
-                                   'Missing keys found: {}'.format(
-                                       missing_keys),
-                                   'Original data: {}'.format(comment),
-                                   'Parsed data: {}'.format(data),
-                                   comment.keys(),
-                                   TwitchChatDownloader._KNOWN_COMMENT_KEYS
-                                   )
+                    self._debug_log(params,
+                                    'Missing keys found: {}'.format(
+                                        missing_keys),
+                                    'Original data: {}'.format(comment),
+                                    'Parsed data: {}'.format(data),
+                                    comment.keys(),
+                                    TwitchChatDownloader._KNOWN_COMMENT_KEYS
+                                    )
 
                 time_in_seconds = data.get('time_in_seconds', 0)
 
@@ -1197,7 +1192,7 @@ def _get_chat_messages_by_vod_id(self, vod_id, params, max_duration, offset=None
                 elif after_end:  # after end
                     return  # while actually searching, if time is invalid
 
-                to_add = self.must_add_item(
+                to_add = self._must_add_item(
                     data,
                     self._MESSAGE_GROUPS,
                     messages_groups_to_add,
@@ -1217,6 +1212,9 @@ def _get_chat_messages_by_vod_id(self, vod_id, params, max_duration, offset=None
             if not cursor:
                 return
 
+    def _get_chat_by_vod_id(self, match, params):
+        return self.get_chat_by_vod_id(match.group('id'), params)
+
     def get_chat_by_vod_id(self, vod_id, params):
         max_attempts = params.get('max_attempts')
         retry_timeout = params.get('retry_timeout')
@@ -1255,6 +1253,9 @@ def get_chat_by_vod_id(self, vod_id, params):
             is_live=False
         )
 
+    def _get_chat_by_clip_id(self, match, params):
+        return self.get_chat_by_clip_id(match.group('id'), params)
+
     def get_chat_by_clip_id(self, clip_id, params):
 
         max_attempts = params.get('max_attempts')
@@ -1309,7 +1310,7 @@ def get_chat_by_clip_id(self, clip_id, params):
     _BADGE_ID_REGEX = r'v1/([^/]+)/'
 
     @staticmethod
-    def parse_badge_info(name, version, channel_id):
+    def _parse_badge_info(name, version, channel_id):
         new_badge = {
             'name': replace_with_underscores(name),
             'version': int_or_none(version, version)
@@ -1317,10 +1318,9 @@ def parse_badge_info(name, version, channel_id):
 
         # prioritise custom emotes (e.g. subscriber and bits)
         channel_id = int(channel_id)
-        new_badge_info = try_get(TwitchChatDownloader._SUBSCRIBER_BADGE_INFO,
-                                 lambda x: x[channel_id][name]['versions'][version]
-                                 ) or try_get(
-                                     TwitchChatDownloader._BADGE_INFO, lambda x: x[name]['versions'][version])
+        new_badge_info = multi_get(
+            TwitchChatDownloader._SUBSCRIBER_BADGE_INFO, channel_id, name, 'versions', version) or multi_get(
+            TwitchChatDownloader._BADGE_INFO, name, 'versions', version)
 
         if new_badge_info:
             for key in TwitchChatDownloader._BADGE_KEYS:
@@ -1332,8 +1332,7 @@ def parse_badge_info(name, version, channel_id):
                 new_badge['icons'] = []
 
             for image_url, size in image_urls:
-                new_badge['icons'].append(
-                    BaseChatDownloader.create_image(image_url, size, size))
+                new_badge['icons'].append(Image(image_url, size, size).json())
 
             if image_urls:
                 badge_id = re.search(
@@ -1344,7 +1343,7 @@ def parse_badge_info(name, version, channel_id):
         return new_badge
 
     @staticmethod
-    def parse_irc_badges(badges, channel_id):
+    def _parse_irc_badges(badges, channel_id):
         info = []
         if not badges:
             return info
@@ -1364,7 +1363,7 @@ def parse_irc_badges(badges, channel_id):
                 ])
                 continue  # TODO debug
 
-            info.append(TwitchChatDownloader.parse_badge_info(
+            info.append(TwitchChatDownloader._parse_badge_info(
                 split[0], split[1], channel_id))
         return info
 
@@ -1419,9 +1418,8 @@ def _parse_irc_item(match):
                 ])
                 continue
 
-            BaseChatDownloader.remap(info, TwitchChatDownloader._IRC_REMAPPING, keys[0], keys[1],
-                                     keep_unknown_keys=True,
-                                     replace_char_with_underscores='-')
+            r.remap(info, TwitchChatDownloader._IRC_REMAPPING,
+                    keys[0], keys[1], keep_unknown_keys=True, replace_char_with_underscores='-')
 
         message_match = match.group(3)
         if message_match:
@@ -1436,10 +1434,10 @@ def _parse_irc_item(match):
         author_badge_metadata = info.pop('author_badge_metadata', [])
         author_badges = info.pop('author_badges', [])
 
-        info['author_badges'] = TwitchChatDownloader.parse_irc_badges(
+        info['author_badges'] = TwitchChatDownloader._parse_irc_badges(
             author_badges, info.get('channel_id'))
 
-        badge_metadata = TwitchChatDownloader.parse_irc_badges(
+        badge_metadata = TwitchChatDownloader._parse_irc_badges(
             author_badge_metadata, info.get('channel_id'))
 
         subscriber_badge = next(
@@ -1454,10 +1452,10 @@ def _parse_irc_item(match):
         if author_display_name:
             info['author_name'] = author_display_name.lower()
 
-        in_reply_to = BaseChatDownloader.move_to_dict(info, 'in_reply_to')
+        in_reply_to = BaseChatDownloader._move_to_dict(info, 'in_reply_to')
 
-        BaseChatDownloader.move_to_dict(in_reply_to, 'author')
-        BaseChatDownloader.move_to_dict(info, 'author')
+        BaseChatDownloader._move_to_dict(in_reply_to, 'author')
+        BaseChatDownloader._move_to_dict(info, 'author')
 
         original_action_type = match.group(2)
 
@@ -1522,7 +1520,7 @@ def create_connection():
                     irc.set_timeout(message_receive_timeout)
                     irc.join_channel(stream_id)
                     return irc
-                except socket.gaierror as e:
+                except (socket.gaierror, ConnectionRefusedError) as e:
                     self.retry(attempt_number, max_attempts, e, retry_timeout)
 
         twitch_chat_irc = create_connection()
@@ -1589,16 +1587,16 @@ def create_connection():
                             missing_keys = data.keys() - TwitchChatDownloader._KNOWN_IRC_KEYS
 
                             if missing_keys:
-                                self.debug_log(params,
-                                               'Missing keys found: {}'.format(
-                                                   missing_keys),
-                                               'Original data: {}'.format(
-                                                   match.groups()),
-                                               'Parsed data: {}'.format(data)
-                                               )
+                                self._debug_log(params,
+                                                'Missing keys found: {}'.format(
+                                                    missing_keys),
+                                                'Original data: {}'.format(
+                                                    match.groups()),
+                                                'Parsed data: {}'.format(data)
+                                                )
                             # check whether to skip this message or not, based on its type
 
-                            to_add = self.must_add_item(
+                            to_add = self._must_add_item(
                                 data,
                                 self._MESSAGE_GROUPS,
                                 messages_groups_to_add,
@@ -1645,6 +1643,9 @@ def create_connection():
         finally:
             twitch_chat_irc.close_connection()
 
+    def _get_chat_by_stream_id(self, match, params):
+        return self.get_chat_by_stream_id(match.group('id'), params)
+
     def get_chat_by_stream_id(self, stream_id, params):
 
         max_attempts = params.get('max_attempts')
@@ -1662,6 +1663,9 @@ def get_chat_by_stream_id(self, stream_id, params):
             except (JSONDecodeError, RequestException) as e:
                 self.retry(attempt_number, max_attempts, e, retry_timeout)
 
+        if not stream_info:
+            raise UserNotFound('Unable to find user: "{}"'.format(stream_id))
+
         is_live = multi_get(stream_info, 'stream', 'type') == 'live'
         title = multi_get(stream_info, 'lastBroadcast',
                           'title') if is_live else None
@@ -1676,27 +1680,27 @@ def get_chat_by_stream_id(self, stream_id, params):
             is_live=is_live
         )
 
-    def get_chat(self,
-                 **kwargs
-                 ):
+    # def get_chat(self,
+    #              **kwargs
+    #              ):
 
-        # get video id
-        url = kwargs.get('url')
+    #     # get video id
+    #     url = kwargs.get('url')
 
-        for regex, function_name in self._REGEX_FUNCTION_MAP:
-            match = re.search(regex, url)
-            if match:
-                return getattr(self, function_name)(match.group('id'), kwargs)
+    #     for regex, function_name in self._REGEX_FUNCTION_MAP:
+    #         match = re.search(regex, url)
+    #         if match:
+    #             return getattr(self, function_name)(match.group('id'), kwargs)
 
         # if(match):
         #     match.group('id')
         #     return self.get_chat_by_video_id(match.group('id'), params)
 
-            # if(match.group('id')):  # normal youtube video
-            #     return
+        # if(match.group('id')):  # normal youtube video
+        #     return
 
-            # else:  # TODO add profile, etc.
-            #     pass
+        # else:  # TODO add profile, etc.
+        #     pass
 
     # def get_chat_messages(self, url):
     #     pass
diff --git a/chat_downloader/sites/youtube.py b/chat_downloader/sites/youtube.py
index 5d785e1..d26f1cd 100644
--- a/chat_downloader/sites/youtube.py
+++ b/chat_downloader/sites/youtube.py
@@ -2,47 +2,49 @@
 from .common import (
     BaseChatDownloader,
     Chat,
-    Remapper as r
+    Remapper as r,
+    Image
 )
 
-from requests.exceptions import RequestException
-
-from json.decoder import JSONDecodeError
-
 from ..errors import (
+    ChatDownloaderError,
     NoChatReplay,
     NoContinuation,
     ParsingError,
     VideoUnavailable,
     LoginRequired,
     VideoUnplayable,
-    InvalidParameter
+    InvalidParameter,
+    UserNotFound
 )
 
-from urllib import parse
-
-import json
-import re
-
 from ..utils import (
     try_get,
     multi_get,
     time_to_seconds,
     seconds_to_time,
     int_or_none,
-    get_colours,
+    float_or_none,
+    arbg_int_to_rgba,
+    rgba_to_hex,
     try_get_first_key,
     try_get_first_value,
     remove_prefixes,
     remove_suffixes,
     camel_case_split,
     ensure_seconds,
-    log,
     attempts,
-    interruptable_sleep,
+    interruptible_sleep,
     try_parse_json
 )
 
+from ..debugging import log
+
+
+import re
+from requests.exceptions import RequestException
+from json.decoder import JSONDecodeError
+from urllib import parse
 from datetime import datetime
 from base64 import b64decode
 
@@ -57,37 +59,6 @@ def __init__(self, **kwargs):
     _SITE_DEFAULT_PARAMS = {
         'format': 'youtube',
     }
-    # _DEFAULT_FORMAT = ''
-
-    # Regex provided by youtube-dl
-    _VALID_URL = r'''(?x)^
-                     (
-                         # http(s):// or protocol-independent URL
-                         (?:https?://|//)
-                         (?:(?:(?:(?:\w+\.)?[yY][oO][uU][tT][uU][bB][eE](?:-nocookie|kids)?\.com/|
-                            youtube\.googleapis\.com/)                        # the various hostnames, with wildcard subdomains
-                         (?:.*?\#/)?                                          # handle anchor (#/) redirect urls
-                         (?:                                                  # the various things that can precede the ID:
-                             # v/ or embed/ or e/
-                             (?:(?:v|embed|e)/(?!videoseries))
-                             |(?:                                             # or the v= param in all its forms
-                                 # preceding watch(_popup|.php) or nothing (like /?v=xxxx)
-                                 (?:(?:watch|movie)(?:_popup)?(?:\.php)?/?)?
-                                 (?:\?|\#!?)                                  # the params delimiter ? or # or #!
-                                 # any other preceding param (like /?s=tuff&v=xxxx or ?s=tuff&amp;v=V36LpHqtcDY)
-                                 (?:.*?[&;])??
-                                 v=
-                             )
-                         ))
-                         |(?:
-                            youtu\.be                                        # just youtu.be/xxxx
-                         )/)
-                     )?                                                       # all until now is optional -> you can pass the naked ID
-                     # here is it! the YouTube video ID
-                     (?P<id>[0-9A-Za-z_-]{11})
-                     # if we found the ID, everything can follow
-                     (?(1).+)?
-                     $'''
 
     _TESTS = [
         # Get top live streams
@@ -114,6 +85,37 @@ def __init__(self, **kwargs):
                 'timeout': 5
             }
         },
+        {
+            'name': 'Get chat messages from livestream, using channel id.',
+            'params': {
+                'url': 'https://www.youtube.com/channel/UCSJ4gkVC6NrvII8umztf0Ow',
+                'timeout': 5
+            }
+        },
+        {
+            'name': 'Get chat messages from livestream, using custom url (1).',
+            'params': {
+                'url': 'https://www.youtube.com/c/lofigirl',
+                'timeout': 5
+            }
+        },
+        {
+            'name': 'Get chat messages from livestream, using custom url (2).',
+            'params': {
+                'url': 'https://www.youtube.com/lofigirl',
+                'timeout': 5
+            }
+        },
+        {
+            'name': 'Get chat messages from livestream, using user id.',
+            'params': {
+                'url': 'https://www.youtube.com/user/YellowBrickCinema',
+                'timeout': 5
+            }
+        },
+
+
+
         {
             'name': 'Get chat messages from live chat replay',
             'params': {
@@ -202,6 +204,7 @@ def __init__(self, **kwargs):
             }
         },
 
+
         # TESTING FOR ERRORS
         {
             'name': 'Video does not exist',
@@ -261,6 +264,7 @@ def __init__(self, **kwargs):
 
     _YT_INITIAL_DATA_RE = r'(?:window\s*\[\s*["\']ytInitialData["\']\s*\]|ytInitialData)\s*=\s*({.+?})\s*;'
     _YT_INITIAL_PLAYER_RESPONSE_RE = r'ytInitialPlayerResponse\s*=\s*({.+?})\s*;'
+    _YT_CFG_RE = r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;'
 
     _YT_HOME = 'https://www.youtube.com'
     _YT_VIDEO_TEMPLATE = _YT_HOME + '/watch?v={}'
@@ -319,11 +323,57 @@ def __init__(self, **kwargs):
     }
 
     _MESSAGE_TYPES = ['all']
-    for group in _MESSAGE_GROUPS:
-        _MESSAGE_TYPES += _MESSAGE_GROUPS[group]
+    for _group in _MESSAGE_GROUPS:
+        _MESSAGE_TYPES += _MESSAGE_GROUPS[_group]
+
+    # Regex provided by youtube-dl
+
+    # TODO differentiate /user/x, /c/y and /channel/UC...
+
+    _VALID_URLS = {
+        '_get_chat_by_video_id': r'''(?x)^
+                     (
+                         # http(s):// or protocol-independent URL
+                         (?:https?://|//)
+                         (?:(?:(?:(?:\w+\.)?[yY][oO][uU][tT][uU][bB][eE](?:-nocookie|kids)?\.com/|
+                            youtube\.googleapis\.com/)                        # the various hostnames, with wildcard subdomains
+                         (?:.*?\#/)?                                          # handle anchor (#/) redirect urls
+                         (?:                                                  # the various things that can precede the ID:
+                             # v/ or embed/ or e/
+                             (?:(?:v|embed|e)/(?!videoseries))
+                             |(?:                                             # or the v= param in all its forms
+                                 # preceding watch(_popup|.php) or nothing (like /?v=xxxx)
+                                 (?:(?:watch|movie)(?:_popup)?(?:\.php)?/?)?
+                                 (?:\?|\#!?)                                  # the params delimiter ? or # or #!
+                                 # any other preceding param (like /?s=tuff&v=xxxx or ?s=tuff&amp;v=V36LpHqtcDY)
+                                 (?:.*?[&;])??
+                                 v=
+                             )
+                         ))
+                         |(?:
+                            youtu\.be                                        # just youtu.be/xxxx
+                         )/)
+                     )?                                                       # all until now is optional -> you can pass the naked ID
+                     # here is it! the YouTube video ID
+                     (?P<id>[0-9A-Za-z_-]{11})''',
+
+
+        # while this does match 'watch' urls, it will never
+        # return this since the above regex is run before this
+        '_get_chat_by_user': r'''(?x)
+                (?:https?://|//)
+                    (?:\w+\.)?
+                    (?:
+                        youtube(?:kids)?\.com
+                    )/
+                    (?:
+                        (?P<type>channel|c|user)/
+                    )?
+                    (?P<id>[a-zA-Z0-9_-]+)'''
+    }
 
     @staticmethod
-    def get_source_image_url(url):
+    def _get_source_image_url(url):
         index = url.find('=')
         if index >= 0:
             return url[0:url.index('=')]
@@ -331,7 +381,7 @@ def get_source_image_url(url):
             return url
 
     @ staticmethod
-    def parse_youtube_link(text):
+    def _parse_youtube_link(text):
         if text.startswith(('/redirect', 'https://www.youtube.com/redirect')):  # is a redirect link
             info = dict(parse.parse_qsl(parse.urlsplit(text).query))
             return info.get('q') or ''
@@ -343,20 +393,23 @@ def parse_youtube_link(text):
             return text
 
     @ staticmethod
-    def parse_navigation_endpoint(navigation_endpoint, default_text=''):
-
-        url = try_get(navigation_endpoint, lambda x: YouTubeChatDownloader.parse_youtube_link(
-            x['commandMetadata']['webCommandMetadata']['url'])) or default_text
-
-        return url
+    def _parse_navigation_endpoint(navigation_endpoint, default_text=''):
+        try:
+            return YouTubeChatDownloader._parse_youtube_link(
+                navigation_endpoint['commandMetadata']['webCommandMetadata']['url'])
+        except Exception:
+            return default_text
 
     @ staticmethod
-    def parse_runs(run_info, parse_links=True):
+    def _parse_runs(run_info, parse_links=True):
         """ Reads and parses YouTube formatted messages (i.e. runs). """
-
         message_info = {
             'message': ''
         }
+
+        if not isinstance(run_info, dict):
+            return message_info
+
         message_emotes = {}
 
         runs = run_info.get('runs') or []
@@ -365,7 +418,7 @@ def parse_runs(run_info, parse_links=True):
                 if parse_links and 'navigationEndpoint' in run:  # is a link and must parse
 
                     # if something fails, use default text
-                    message_info['message'] += YouTubeChatDownloader.parse_navigation_endpoint(
+                    message_info['message'] += YouTubeChatDownloader._parse_navigation_endpoint(
                         run['navigationEndpoint'], run['text'])
 
                 else:  # is a normal message
@@ -377,13 +430,15 @@ def parse_runs(run_info, parse_links=True):
                 name = emoji['shortcuts'][0]
 
                 if emoji_id and emoji_id not in message_emotes:
+
+                    # TODO change to remapping?
                     message_emotes[emoji_id] = {
                         'id': emoji_id,
                         'name': name,
-                        'shortcuts': emoji['shortcuts'],
-                        'search_terms': emoji['searchTerms'],
-                        'images': YouTubeChatDownloader.parse_thumbnails(emoji['image']),
-                        'is_custom_emoji': emoji['isCustomEmoji']
+                        'shortcuts': emoji.get('shortcuts'),
+                        'search_terms': emoji.get('searchTerms'),
+                        'images': YouTubeChatDownloader._parse_thumbnails(emoji.get('image', {})),
+                        'is_custom_emoji': emoji.get('isCustomEmoji', False)
                     }
 
                 message_info['message'] += name
@@ -409,14 +464,17 @@ def _parse_item(item, info=None):
             return info
 
         for key in item_info:
-            BaseChatDownloader.remap(
-                info, YouTubeChatDownloader._REMAPPING, key, item_info[key])
+            r.remap(info, YouTubeChatDownloader._REMAPPING,
+                    key, item_info[key])
 
         # check for colour information
         for colour_key in YouTubeChatDownloader._COLOUR_KEYS:
             if colour_key in item_info:  # if item has colour information
-                info[camel_case_split(colour_key.replace('Color', 'Colour'))] = get_colours(
-                    item_info[colour_key]).get('hex')
+                rgba_colour = arbg_int_to_rgba(item_info[colour_key])
+                hex_colour = rgba_to_hex(rgba_colour)
+                new_key = camel_case_split(
+                    colour_key.replace('Color', 'Colour'))
+                info[new_key] = hex_colour
 
         item_endpoint = item_info.get('showItemEndpoint')
         if item_endpoint:  # has additional information
@@ -426,7 +484,7 @@ def _parse_item(item, info=None):
             if renderer:
                 info.update(YouTubeChatDownloader._parse_item(renderer))
 
-        BaseChatDownloader.move_to_dict(info, 'author')
+        BaseChatDownloader._move_to_dict(info, 'author')
 
         # TODO determine if youtube glitch has occurred
         # round(time_in_seconds/timestamp) == 1
@@ -455,7 +513,7 @@ def _parse_item(item, info=None):
         return info
 
     @ staticmethod
-    def parse_badges(badge_items):
+    def _parse_badges(badge_items):
         badges = []
 
         for badge in badge_items:
@@ -482,10 +540,10 @@ def parse_badges(badge_items):
                         if matches:
                             size = int(matches.group(1))
                             to_add['icons'].append(
-                                BaseChatDownloader.create_image(url, size, size))
+                                Image(url, size, size).json())
                 if url:
-                    to_add['icons'].insert(0, BaseChatDownloader.create_image(
-                        YouTubeChatDownloader.get_source_image_url(url), image_id='source'))
+                    to_add['icons'].insert(0, Image(
+                        YouTubeChatDownloader._get_source_image_url(url), image_id='source').json())
 
             badges.append(to_add)
 
@@ -495,7 +553,7 @@ def parse_badges(badge_items):
         return badges
 
     @ staticmethod
-    def parse_thumbnails(item):
+    def _parse_thumbnails(item):
 
         # sometimes thumbnails come as a list
         if isinstance(item, list):
@@ -506,27 +564,29 @@ def parse_thumbnails(item):
         # https://yt3.ggpht.com/ytc/AAUvwnhBYeK7_iQTJbXe6kIMpMlCI2VsVHhb6GBJuYeZ
 
         thumbnails = item.get('thumbnails') or []
-        final = list(map(lambda x: BaseChatDownloader.create_image(
+        final = list(map(lambda x: Image(
             x.get('url'),
             x.get('width'),
             x.get('height'),
-        ), thumbnails))
+        ).json(), thumbnails))
 
         if len(final) > 0:
-            final.insert(0, BaseChatDownloader.create_image(
-                YouTubeChatDownloader.get_source_image_url(final[0]['url']), image_id='source'))
+            final.insert(0, Image(
+                YouTubeChatDownloader._get_source_image_url(final[0]['url']), image_id='source').json())
 
         return final
 
     @ staticmethod
-    def parse_action_button(item):
+    def _parse_action_button(item):
+        endpoint = multi_get(item, 'buttonRenderer', 'navigationEndpoint')
+
         return {
-            'url': try_get(item, lambda x: YouTubeChatDownloader.parse_navigation_endpoint(x['buttonRenderer']['navigationEndpoint'])) or '',
+            'url': YouTubeChatDownloader._parse_navigation_endpoint(endpoint) if endpoint else '',
             'text': multi_get(item, 'buttonRenderer', 'text', 'simpleText') or ''
         }
 
     @staticmethod
-    def get_simple_text(item):
+    def _get_simple_text(item):
         return item.get('simpleText')
 
     _CURRENCY_SYMBOLS = {
@@ -554,7 +614,7 @@ def get_simple_text(item):
     # e.g. 'CHF', 'COP', 'HUF', 'PHP', 'PLN', 'RUB', 'SEK', 'PEN', 'ARS', 'CLP', 'NOK', 'BAM', 'SGD'
 
     @staticmethod
-    def parse_currency(item):
+    def _parse_currency(item):
         mixed_text = item.get('simpleText') or str(item)
 
         info = re.split(r'([\d,\.]+)', mixed_text)
@@ -578,62 +638,63 @@ def parse_currency(item):
     _REMAPPING = {
         'id': 'message_id',
         'authorExternalChannelId': 'author_id',
-        'authorName': r('author_name', get_simple_text),
+        'authorName': r('author_name', _get_simple_text),
         # TODO author_display_name
-        'purchaseAmountText': r('money', parse_currency),
-        'message': r(None, parse_runs, True),
-        'timestampText': r('time_text', get_simple_text),
+        'purchaseAmountText': r('money', _parse_currency),
+        'message': r(None, _parse_runs, True),
+        'timestampText': r('time_text', _get_simple_text),
         'timestampUsec': r('timestamp', int_or_none),
 
-        'authorPhoto': r('author_images', parse_thumbnails),
+        'authorPhoto': r('author_images', _parse_thumbnails),
 
         'tooltip': 'tooltip',
 
         'icon': r('icon', lambda x: x.get('iconType')),
-        'authorBadges': r('author_badges', parse_badges),
+        'authorBadges': r('author_badges', _parse_badges),
 
         # stickers
-        'sticker': r('sticker_images', parse_thumbnails),
+        'sticker': r('sticker_images', _parse_thumbnails),
 
         # ticker_paid_message_item
         'fullDurationSec': r('ticker_duration', int_or_none),
-        'amount': r('money', parse_currency),
+        'amount': r('money', _parse_currency),
 
 
         # ticker_sponsor_item
-        'detailText': r(None, parse_runs, True),
-        'customThumbnail': r('badge_icons', parse_thumbnails),
+        'detailText': r(None, _parse_runs, True),
+        'customThumbnail': r('badge_icons', _parse_thumbnails),
 
         # membership_item
-        'headerSubtext': r(None, parse_runs, True),
-        'sponsorPhoto': r('sponsor_icons', parse_thumbnails),
+        'headerSubtext': r(None, _parse_runs, True),
+        'sponsorPhoto': r('sponsor_icons', _parse_thumbnails),
 
         # ticker_paid_sticker_item
-        'tickerThumbnails': r('ticker_icons', parse_thumbnails),
+        'tickerThumbnails': r('ticker_icons', _parse_thumbnails),
 
         # deleted messages
-        'deletedStateMessage': r(None, parse_runs, True),
+        'deletedStateMessage': r(None, _parse_runs, True),
         'targetItemId': 'target_message_id',
 
         'externalChannelId': 'author_id',
 
         # action buttons
-        'actionButton': r('action', parse_action_button),
+        'actionButton': r('action', _parse_action_button),
 
         # addBannerToLiveChatCommand
-        'text': r(None, parse_runs, True),
+        'text': r(None, _parse_runs, True),
         'viewerIsCreator': 'viewer_is_creator',
         'targetId': 'target_message_id',
         'isStackable': 'is_stackable',
+        'backgroundType': 'background_type',
 
         # removeBannerForLiveChatCommand
         'targetActionId': 'target_message_id',
 
         # donation_announcement
-        'subtext': r(None, parse_runs, True),
+        'subtext': r(None, _parse_runs, True),
 
         # tooltip
-        'detailsText': r(None, parse_runs, True),
+        'detailsText': r(None, _parse_runs, True),
 
     }
 
@@ -804,8 +865,8 @@ def parse_currency(item):
         'liveChatPlaceholderItemRenderer'
     ]
     _KNOWN_MESSAGE_TYPES = []
-    for action in _KNOWN_ACTION_TYPES:
-        _KNOWN_MESSAGE_TYPES += _KNOWN_ACTION_TYPES[action]
+    for _action in _KNOWN_ACTION_TYPES:
+        _KNOWN_MESSAGE_TYPES += _KNOWN_ACTION_TYPES[_action]
 
     _KNOWN_SEEK_CONTINUATIONS = [
         'playerSeekContinuationData'
@@ -818,20 +879,18 @@ def parse_currency(item):
 
     _KNOWN_CONTINUATIONS = _KNOWN_SEEK_CONTINUATIONS + _KNOWN_CHAT_CONTINUATIONS
 
-    @staticmethod
-    def generate_urls(**kwargs):
-        downloader = YouTubeChatDownloader()
-        items = downloader.get_testing_items()
+    def generate_urls(self, **kwargs):
+        items = self._get_testing_items()
 
         for item in items:
-            yield YouTubeChatDownloader._YT_VIDEO_TEMPLATE.format(item['video_id'])
+            yield self._YT_VIDEO_TEMPLATE.format(item['video_id'])
         # print('b')
 
         # downloader.get_playlist_items
 
     _LIVE_PLAYLIST_URL = _YT_HOME + '/channel/UC4R8DWoMoI7CAwX8_LjQHig'
 
-    def get_testing_items(self):
+    def _get_testing_items(self):
 
         html, yt_info = self._get_initial_info(self._LIVE_PLAYLIST_URL)
 
@@ -853,25 +912,107 @@ def get_testing_items(self):
 
             yield from self.get_playlist_items(playlist_url)
 
+    @staticmethod
+    def _get_rendered_content(yt_info, tab_index=0):
+        return yt_info['contents']['twoColumnBrowseResultsRenderer']['tabs'][tab_index]['tabRenderer']['content'][
+            'sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]
+
+    _VIDEO_REMAPPING = {
+        'videoId': 'video_id',
+        'title': r('title', lambda x: YouTubeChatDownloader._parse_runs(x)['message']),
+        'viewCountText': r('view_count', lambda x: YouTubeChatDownloader._parse_runs(x)['message']),
+        'shortViewCountText': r('short_view_count', lambda x: YouTubeChatDownloader._parse_runs(x)['message']),
+
+        # 'videoId', 'thumbnail', 'title', 'viewCountText', 'navigationEndpoint', 'ownerBadges', 'trackingParams', 'shortViewCountText', 'menu', 'thumbnailOverlays'
+    }
+
+    @staticmethod
+    def _parse_video(video_renderer):
+        return r.remap_dict(video_renderer, YouTubeChatDownloader._VIDEO_REMAPPING)
+
+    _VIDEO_TYPE_REMAPPING = {
+        'live': (501, 'Live now'),
+        'upcoming': (502, 'Upcoming live streams'),
+        'past': (503, 'Past live streams')
+    }
+
+    def get_user_videos(self, channel_id=None, user_id=None, custom_username=None, video_type='live'):
+        """[summary]
+        If more than one of `channel_id`, `user_id` and `custom_username`
+        are specifed, the first one specified will be returned.
+
+        :param channel_id: [description], defaults to None
+        :type channel_id: [type], optional
+        :param user_id: [description], defaults to None
+        :type user_id: [type], optional
+        :param custom_username: [description], defaults to None
+        :type custom_username: [type], optional
+        :raises ValueError: [description]
+        """
+
+        _id = ''
+        _type = ''
+        if channel_id:
+            _id = channel_id
+            _type = 'channel'
+        elif user_id:
+            _id = user_id
+            _type = 'user'
+        elif custom_username:
+            _id = custom_username
+            _type = 'c'
+        else:
+            raise ValueError('No user type specified.')
+
+        # live, past, upcoming
+        vid_type = self._VIDEO_TYPE_REMAPPING.get(video_type.lower())
+
+        if not vid_type:
+            raise ValueError('Invalid argument passed for video_type. Must be one of {}'.format(
+                set(self._VIDEO_TYPE_REMAPPING.keys())))
+
+        user_url = 'https://www.youtube.com/{}/{}'.format(_type, _id)
+
+        html, yt_info = self._get_initial_info(
+            '{}/videos?view=2&live_view={}'.format(user_url, vid_type[0]))
+
+        section_list_renderer = multi_get(
+            yt_info, 'contents', 'twoColumnBrowseResultsRenderer', 'tabs', 1, 'tabRenderer', 'content', 'sectionListRenderer')
+
+        if not section_list_renderer:
+            raise UserNotFound('Unable to find user: "{}"'.format(user_url))
+
+        # Check that the returned grid is what was asked for
+        # YouTube tries to correct your mistake by selecting the uploads tab
+        # if you try to access a tab that is not visible.
+        sub_menu_items = section_list_renderer['subMenu']['channelSubMenuRenderer']['contentTypeSubMenuItems']
+        selected = list(filter(lambda x: x['selected'], sub_menu_items))
+        if not selected or selected[0]['title'] != vid_type[1]:
+            log('debug', '"{}" tab is not visible for this channel (i.e. there are no such videos).'.format(
+                vid_type[1]))
+            return
+
+        items = section_list_renderer['contents'][0]['itemSectionRenderer']['contents'][0]['gridRenderer']['items']
+
+        for item in items:
+            vid = item.get('gridVideoRenderer')
+            if vid:
+                yield self._parse_video(vid)
+            else:
+                print(item)
+
     def get_playlist_items(self, playlist_url):
 
         html, yt_info = self._get_initial_info(playlist_url)
 
-        items = yt_info['contents']['twoColumnBrowseResultsRenderer']['tabs'][0]['tabRenderer']['content'][
-            'sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['playlistVideoListRenderer']['contents']
+        items = self._get_rendered_content(
+            yt_info)['playlistVideoListRenderer']['contents']
 
         for item in items:
             playlist_video = item.get('playlistVideoRenderer')
 
             if playlist_video:
-                # print(playlist_video)
-
-                item = {
-                    'video_id': playlist_video.get('videoId'),
-                    'title': self.parse_runs(playlist_video.get('title'))['message'],
-                }
-
-                yield item
+                yield self._parse_video(playlist_video)
 
             # "continuationItemRenderer":{
             #     "trigger":"CONTINUATION_TRIGGER_ON_ITEM_SHOWN",
@@ -917,11 +1058,9 @@ def _get_initial_video_info(self, video_id):
         streaming_data = player_response_info.get('streamingData') or {}
         formats = streaming_data.get(
             'adaptiveFormats') or streaming_data.get('formats')
-        last_modified = try_get(formats, lambda x: float(x[0]['lastModified']))
 
         details = {
-            'start_time': last_modified,
-            'visitor_data': multi_get(yt_initial_data, 'responseContext', 'webResponseContextExtensionData', 'ytConfigData', 'visitorData')
+            'start_time': float_or_none(multi_get(formats, 0, 'lastModified')),
         }
 
         # Try to get continuation info
@@ -955,8 +1094,8 @@ def _get_initial_video_info(self, video_id):
                 for error_reason in error_reasons:
                     text = error_info.get(error_reason) or {}
 
-                    error_reasons[error_reason] = text.get('simpleText') or try_get(
-                        text, lambda x: self.parse_runs(x, False)['message']) or error_info.pop(
+                    error_reasons[error_reason] = text.get('simpleText') or self._parse_runs(
+                        text, False)['message'] or error_info.pop(
                         'itemTitle', '') or error_info.pop(
                             'offerDescription', '') or playability_status.get(error_reason) or ''
 
@@ -983,13 +1122,15 @@ def _get_initial_video_info(self, video_id):
                     error_message = '{}: {}'.format(status, error_message)
                     raise VideoUnavailable(error_message)
             elif not contents:
+                log('debug', 'Initial YouTube data: {}'.format(yt_initial_data))
                 raise VideoUnavailable(
                     'Unable to find initial video contents.')
             else:
                 # Video exists, but you cannot view chat for some reason
-                error_message = try_get(conversation_bar, lambda x: self.parse_runs(
-                    x['conversationBarRenderer']['availabilityMessage']['messageRenderer']['text'], False)['message']) or \
-                    'Video does not have a chat replay.'
+
+                error_runs = multi_get(conversation_bar, 'conversationBarRenderer', 'availabilityMessage', 'messageRenderer', 'text')
+                error_message = self._parse_runs(error_runs, False)['message'] if error_runs else 'Video does not have a chat replay.'
+
                 raise NoChatReplay(error_message)
 
         video_details = player_response_info.get('videoDetails') or {}
@@ -1003,7 +1144,6 @@ def _get_chat_messages(self, initial_info, params):
 
         # stream_start_time = initial_info.get('start_time')
         is_live = initial_info.get('is_live')
-        visitor_data = initial_info.get('visitor_data')
 
         # duration = initial_info.get('duration')
 
@@ -1014,16 +1154,15 @@ def _get_chat_messages(self, initial_info, params):
         # Live chat replay - All messages are visible
         chat_type = params.get('chat_type').title()  # Live or Top
 
-        continuation_values = list(initial_continuation_info.values())
-        continuation_keys = list(initial_continuation_info.keys())
-        if len(continuation_values) < 2:
+        continuation_items = list(initial_continuation_info.items())
+        if len(continuation_items) < 2:
             raise NoContinuation(
                 'Initial continuation information could not be found.'.format(initial_continuation_info))
 
         continuation_index = 0 if chat_type == 'Top' else 1
-        continuation = continuation_values[continuation_index]
+        continuation = continuation_items[continuation_index][1]
         log('debug', 'Getting {} chat ({}).'.format(
-            chat_type, continuation_keys[continuation_index]))
+            chat_type, continuation_items[continuation_index][0]))
 
         api_type = 'live_chat'
         if not is_live:
@@ -1034,20 +1173,16 @@ def _get_chat_messages(self, initial_info, params):
         # must run to get first few messages, otherwise might miss some
         html, yt_info = self._get_initial_info(init_page)
 
-        continuation_url = self._YOUTUBE_CHAT_API_TEMPLATE.format(api_type)
+        cfg = re.search(self._YT_CFG_RE, html)
+        yt_cfg_data = try_parse_json(cfg.group(1)) if cfg else {}
+
         continuation_params = {
-            'context': {
-                'client': {
-                    'visitorData': visitor_data,
-                    'userAgent': self.get_session_headers('User-Agent'),
-                    'clientName': 'WEB',
-                    'clientVersion': '2.{}.01.00'.format(datetime.today().strftime('%Y%m%d'))
-                }
-            }
+            'context': yt_cfg_data.get('INNERTUBE_CONTEXT') or {}
         }
 
+        continuation_url = self._YOUTUBE_CHAT_API_TEMPLATE.format(api_type)
         offset_milliseconds = (
-            start_time * 1000) if isinstance(start_time, int) else None
+            start_time * 1000) if isinstance(start_time, (float, int)) else None
 
         # force_no_timeout = params.get('force_no_timeout')
 
@@ -1081,7 +1216,6 @@ def _get_chat_messages(self, initial_info, params):
                         if not is_live and offset_milliseconds is not None:
                             continuation_params['currentPlayerState'] = {
                                 'playerOffsetMs': offset_milliseconds}
-
                         log('debug', 'Continuation: {}'.format(continuation))
 
                         yt_info = self._session_post(
@@ -1091,6 +1225,8 @@ def _get_chat_messages(self, initial_info, params):
                         yt_info, 'continuationContents', 'liveChatContinuation')
 
                     if not info:
+                        log('debug', 'No continuation information found: {}'.format(
+                            yt_info))
                         return
 
                     break  # successful retrieve
@@ -1103,8 +1239,6 @@ def _get_chat_messages(self, initial_info, params):
 
             actions = info.get('actions') or []
 
-            # print(actions)
-
             if actions:
                 for action in actions:
                     # print(action)
@@ -1186,13 +1320,13 @@ def _get_chat_messages(self, initial_info, params):
                             data.update(parsed_contents)
                             data['header_message'] = header_message
                         else:
-                            self.debug_log(params,
-                                           'No bannerRenderer item',
-                                           'Action type: {}'.format(
-                                               original_action_type),
-                                           'Action: {}'.format(action),
-                                           'Parsed data: {}'.format(data)
-                                           )
+                            self._debug_log(params,
+                                            'No bannerRenderer item',
+                                            'Action type: {}'.format(
+                                                original_action_type),
+                                            'Action: {}'.format(action),
+                                            'Parsed data: {}'.format(data)
+                                            )
 
                     elif original_action_type in self._KNOWN_REMOVE_BANNER_TYPES:
                         original_item = action
@@ -1204,12 +1338,12 @@ def _get_chat_messages(self, initial_info, params):
                         # ignore these
                     else:
                         # not processing these
-                        self.debug_log(params,
-                                       'Unknown action: {}'.format(
-                                           original_action_type),
-                                       action,
-                                       data
-                                       )
+                        self._debug_log(params,
+                                        'Unknown action: {}'.format(
+                                            original_action_type),
+                                        action,
+                                        data
+                                        )
 
                     test_for_missing_keys = original_item.get(
                         original_message_type, {}).keys()
@@ -1217,23 +1351,23 @@ def _get_chat_messages(self, initial_info, params):
 
                     # print(action)
                     if not data:  # TODO debug
-                        self.debug_log(params,
-                                       'Parse of action returned empty results: {}'.format(
-                                           original_action_type),
-                                       action
-                                       )
+                        self._debug_log(params,
+                                        'Parse of action returned empty results: {}'.format(
+                                            original_action_type),
+                                        action
+                                        )
 
                     if missing_keys:  # TODO debugging for missing keys
-                        self.debug_log(params,
-                                       'Missing keys found: {}'.format(
-                                           missing_keys),
-                                       'Message type: {}'.format(
-                                           original_message_type),
-                                       'Action type: {}'.format(
-                                           original_action_type),
-                                       'Action: {}'.format(action),
-                                       'Parsed data: {}'.format(data)
-                                       )
+                        self._debug_log(params,
+                                        'Missing keys found: {}'.format(
+                                            missing_keys),
+                                        'Message type: {}'.format(
+                                            original_message_type),
+                                        'Action type: {}'.format(
+                                            original_action_type),
+                                        'Action: {}'.format(action),
+                                        'Parsed data: {}'.format(data)
+                                        )
 
                     if original_message_type:
 
@@ -1247,30 +1381,30 @@ def _get_chat_messages(self, initial_info, params):
                             continue
                             # skip placeholder items
                         elif original_message_type not in self._KNOWN_ACTION_TYPES[original_action_type]:
-                            self.debug_log(params,
-                                           'Unknown message type "{}" for action "{}"'.format(
-                                               original_message_type,
-                                               original_action_type
-                                           ),
-                                           'New message type: {}'.format(
-                                               data['message_type']),
-                                           'Action: {}'.format(action),
-                                           'Parsed data: {}'.format(data)
-                                           )
+                            self._debug_log(params,
+                                            'Unknown message type "{}" for action "{}"'.format(
+                                                original_message_type,
+                                                original_action_type
+                                            ),
+                                            'New message type: {}'.format(
+                                                data['message_type']),
+                                            'Action: {}'.format(action),
+                                            'Parsed data: {}'.format(data)
+                                            )
 
                     else:  # no type # can ignore message
-                        self.debug_log(params,
-                                       'No message type',
-                                       'Action type: {}'.format(
-                                           original_action_type),
-                                       'Action: {}'.format(action),
-                                       'Parsed data: {}'.format(data)
-                                       )
+                        self._debug_log(params,
+                                        'No message type',
+                                        'Action type: {}'.format(
+                                            original_action_type),
+                                        'Action: {}'.format(action),
+                                        'Parsed data: {}'.format(data)
+                                        )
                         continue
 
                     # check whether to skip this message or not, based on its type
 
-                    to_add = self.must_add_item(
+                    to_add = self._must_add_item(
                         data,
                         self._MESSAGE_GROUPS,
                         messages_groups_to_add,
@@ -1335,25 +1469,36 @@ def _get_chat_messages(self, initial_info, params):
                     pass
                     # ignore these continuations
                 else:
-                    self.debug_log(params,
-                                   'Unknown continuation: {}'.format(
-                                       continuation_key),
-                                   cont
-                                   )
+                    self._debug_log(params,
+                                    'Unknown continuation: {}'.format(
+                                        continuation_key),
+                                    cont
+                                    )
 
                 # sometimes continuation contains timeout info
                 sleep_duration = continuation_info.get('timeoutMs')
                 # and not actions:# and not force_no_timeout:
-                if sleep_duration and sleep_duration > 0:
-                    # if there is timeout info, there were no actions and the user
-                    # has not chosen to force no timeouts, then sleep.
-                    # This is useful for streams with varying number of messages
-                    # being sent per second. Timeouts help prevent 429 errors
-                    # (caused by too many requests)
+                if sleep_duration:
+                    # Timeouts help prevent 429 errors (caused by too many requests).
+                    #
+                    # A single request to the YouTube live chat endpoint seems to only
+                    # go back around 10 seconds (only retrieving around 150-200 messages
+                    # at any given time).
+                    #
+                    # For very large livestreams, YouTube sometimes sets timeouts to be
+                    # more than 10 seconds (most likely to alleviate server stress).
+                    # This means that, normally, users will not be able to see all chat
+                    # messages (leaving a gap of timeout - 10 seconds).
+                    #
+                    # To get around this, we clamp the timeout to be between 0 and 8000
+                    # milliseconds (a 2 second window for making the next request).
+                    # This ensures that no messages are missed and we do spam YouTube
+                    # with requests (which may lead to 429 errors or IP blocking).
+
+                    sleep_duration = max(min(sleep_duration, 8000), 0)
 
                     log('debug', 'Sleeping for {}ms.'.format(sleep_duration))
-                    # print('time_until_timeout',timeout.time_until_timeout())
-                    interruptable_sleep(sleep_duration / 1000)
+                    interruptible_sleep(sleep_duration / 1000)
 
             if no_continuation:  # no continuation, end
                 break
@@ -1361,9 +1506,110 @@ def _get_chat_messages(self, initial_info, params):
             if first_time:
                 first_time = False
 
+    # def get_chat_by_user(self, channel_id=None, user_id=None, custom_username=None):
+    #     def get_user_videos(self, video_type='live'):
+
+    def _get_chat_by_user(self, match, params):
+        match_id = match.group('id')
+        user_type = match.group('type')  # channel|c|user
+
+        if user_type == 'channel':
+            return self.get_chat_by_channel_id(match_id, params)
+
+        if user_type == 'user':
+            return self.get_chat_by_user_id(match_id, params)
+
+        # Otherwise assume custom username
+        return self.get_chat_by_custom_username(match_id, params)
+
+    def get_chat_by_channel_id(self, channel_id, params):
+        return self._get_chat_by_user_args({
+            'channel_id': channel_id
+        }, params)
+
+    def get_chat_by_user_id(self, user_id, params):
+        """
+        Such as NASAtelevision in https://www.youtube.com/user/NASAtelevision
+
+        :param user_id:
+        :type user_id: [type]
+        """
+        return self._get_chat_by_user_args({
+            'user_id': user_id
+        }, params)
+
+    def get_chat_by_custom_username(self, custom_username, params):
+        return self._get_chat_by_user_args({
+            'custom_username': custom_username
+        }, params)
+
+    def _get_chat_by_user_args(self, user_video_args, params):
+        # TODO add param for wait time
+        params['retry_timeout'] = 30
+        params['exit_on_fail'] = True
+
+        title = try_get_first_value(user_video_args)
+
+        chat_item = Chat(title=title)  # Create empty chat object
+        chat_item.chat = self._get_chat_messages_by_user_args(
+            user_video_args, chat_item, params)
+
+        return chat_item
+
+    def _get_chat_messages_by_user_args(self, user_video_args, chat_item, params):
+        # chat_item allows to change title and info based on new info
+
+        list_of_vids_to_ignore = params.get('ignore') or []
+
+        while True:
+            for video_type in ('live', 'upcoming'):
+                # prioritise live videos
+                for video in self.get_user_videos(**user_video_args, video_type=video_type):
+                    video_id = video['video_id']
+                    video_title = video['title']
+
+                    if video_id in list_of_vids_to_ignore:
+                        log('debug', 'Skipping video with ID: "{}"'.format(video_id))
+                        continue
+
+                    try:
+                        chat = self.get_chat_by_video_id(video_id, params)
+
+                        log('info', 'Found a{} livestream: "{}" ({}).'.format(
+                            'n upcoming' if video_type == 'upcoming' else '', video_title, video_id))
+
+                        # update chat item by copying over
+                        chat_dict = {k: v for k,
+                                     v in chat.__dict__.items() if k != 'chat'}
+                        for i in chat_dict:
+                            setattr(chat_item, i, chat_dict[i])
+
+                        yield from chat.chat
+
+                    except ChatDownloaderError as e:
+                        # For some reason, doesn't work
+                        log('warning', 'Unable to get chat for "{}" ({}) due to an error: "{}"'.format(
+                            video['title'], video_id, e))
+                        # TODO exit on error?
+
+            sleep_amount = 30
+            if isinstance(params['retry_timeout'], (float, int)):
+                sleep_amount = params['retry_timeout']
+
+            log('info', 'There are no active or upcoming livestreams with a live chat. Retrying again in {} seconds.'.format(
+                sleep_amount))
+            interruptible_sleep(sleep_amount)
+
+            # continue forever, until reaching a video with valid chat
+
     def get_chat_by_video_id(self, video_id, params):
-        """ Get chat messages for a YouTube video, given its ID. """
+        """Get chat messages for a YouTube video, given its ID.
 
+        :param video_id: YouTube video ID
+        :type video_id: str
+        :return: Chat object for the corresponding YouTube video
+        :rtype: Chat
+        """
         initial_info = self._get_initial_video_info(video_id)
 
         title = initial_info.get('title')
@@ -1379,21 +1625,5 @@ def get_chat_by_video_id(self, video_id, params):
             start_time=start_time
         )
 
-    def get_chat(self,
-                 **kwargs
-                 ):
-
-        # get video id
-        url = kwargs.get('url')
-        match = re.search(self._VALID_URL, url)
-
-        if match:
-            video_id = match.group('id')
-            if video_id:  # normal youtube video
-                return self.get_chat_by_video_id(match.group('id'), kwargs)
-
-        #     else:  # TODO add profile, etc.
-        #         pass
-        # else:
-        #     pass
-            # Raise unsupported URL type
+    def _get_chat_by_video_id(self, match, params):
+        return self.get_chat_by_video_id(match.group('id'), params)
diff --git a/chat_downloader/utils.py b/chat_downloader/utils.py
index eb6a6bc..4d9f850 100644
--- a/chat_downloader/utils.py
+++ b/chat_downloader/utils.py
@@ -1,11 +1,9 @@
 import inspect
 import threading
 import _thread
-import colorlog
 import datetime
 import re
 import sys
-import os
 import locale
 import collections.abc
 import io
@@ -14,27 +12,41 @@
 
 
 def timestamp_to_microseconds(timestamp):
+    """Convert RFC3339 timestamp to microseconds. This is needed since
+        ``datetime.datetime.strptime()`` does not support nanosecond precision.
+
+    :param timestamp: RFC3339 timestamp
+    :type timestamp: str
+    :return: The number of microseconds of the timestamp
+    :rtype: int
     """
-    Convert RFC3339 timestamp to microseconds.
-    This is needed as datetime.datetime.strptime() does not support nanosecond precision.
-    """
+
     info = list(filter(None, re.split(r'[\.|Z]{1}', timestamp))) + [0]
     return round((datetime.datetime.strptime('{}Z'.format(info[0]), '%Y-%m-%dT%H:%M:%SZ').timestamp() + float('0.{}'.format(info[1]))) * 1e6)
 
 
 def time_to_seconds(time):
-    """Convert timestamp string of the form 'hh:mm:ss' to seconds."""
-    return int(sum(abs(int(x)) * 60 ** i for i, x in enumerate(reversed(time.replace(',', '').split(':')))) * (-1 if time[0] == '-' else 1))
+    """Convert timestamp string of the form 'hh:mm:ss' to seconds.
 
+    :param time: Timestamp of the form 'hh:mm:ss'
+    :type time: str
+    :return: The corresponding number of seconds
+    :rtype: int
+    """
+    if not time:
+        return 0
+    return int(sum(abs(int(x)) * 60 ** i for i, x in enumerate(reversed(time.replace(',', '').split(':')))) * (-1 if time[0] == '-' else 1))
 
-# def seconds_to_time(seconds):
-#     """Convert seconds to timestamp."""
-#     t = datetime.timedelta(0, abs(seconds))
-#     # time_string = t.days()
-#     return ('-' if seconds < 0 else '') + re.sub(r'^0:0?', '', str(t))
 
 def seconds_to_time(seconds):
-    """Convert seconds to timestamp."""
+    """Convert seconds to timestamp. Note that leading zeroes are omitted
+        when seconds > 60
+
+    :param seconds: Number of seconds
+    :type seconds: int
+    :return: The corresponding timestamp string
+    :rtype: str
+    """
     h, remainder = divmod(abs(seconds), 3600)
     m, s = divmod(remainder, 60)
     time_string = '{}:{:02}:{:02}'.format(int(h), int(m), int(s))
@@ -42,17 +54,35 @@ def seconds_to_time(seconds):
 
 
 def microseconds_to_timestamp(microseconds, format='%Y-%m-%d %H:%M:%S'):
-    """Convert unix time to human-readable timestamp."""
+    """Convert unix time to human-readable timestamp.
+
+    :param microseconds: UNIX microseconds
+    :type microseconds: float
+    :param format: The format string, defaults to '%Y-%m-%d %H:%M:%S'. For
+        information on supported codes, see https://strftime.org/ and
+        https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
+    :type format: str, optional
+    :return: Human readable timestamp corresponding to the format
+    :rtype: str
+    """
     return datetime.datetime.fromtimestamp(microseconds // 1000000).strftime(format)
 
 
 def ensure_seconds(time, default=None):
-    """Ensure time is returned in seconds."""
+    """Ensure time is returned in seconds.
+
+    :param time: The time, in seconds or 'hh:mm:ss'.
+    :type time: Union[float, str]
+    :param default: Returns this if unable to parse the time, defaults to None
+    :type default: object, optional
+    :return: The corresponding number of seconds
+    :rtype: float
+    """
     if time is None:  # if time is none, return default
         return default
 
     try:
-        return int(time)
+        return float(time)
     except ValueError:
         return time_to_seconds(time)
     except Exception:
@@ -60,7 +90,13 @@ def ensure_seconds(time, default=None):
 
 
 def arbg_int_to_rgba(argb_int):
-    """Convert ARGB integer to RGBA array."""
+    """Convert ARGB integer to RGBA array.
+
+    :param argb_int: ARGB integer
+    :type argb_int: int
+    :return: RGBA array
+    :rtype: list[int]
+    """
     red = (argb_int >> 16) & 255
     green = (argb_int >> 8) & 255
     blue = argb_int & 255
@@ -69,29 +105,21 @@ def arbg_int_to_rgba(argb_int):
 
 
 def rgba_to_hex(colours):
-    """Convert RGBA array to hex colour."""
-    return '#{:02x}{:02x}{:02x}{:02x}'.format(*colours)
+    """Convert RGBA array to hex colour.
 
+    :param colours: RGBA array
+    :type colours: list[int]
+    :return: Corresponding hexadecimal representation
+    :rtype: str
+    """
+    return '#{:02x}{:02x}{:02x}{:02x}'.format(*colours)
 
-def get_colours(argb_int):
-    """Given an ARGB integer, return both RGBA and hex values."""
-    rgba_colour = arbg_int_to_rgba(argb_int)
-    hex_colour = rgba_to_hex(rgba_colour)
-    return {
-        'argb_int': argb_int,
-        'rgba': rgba_colour,
-        'hex': hex_colour
-    }
 
 # from youtube-dl
-
-
 def try_get(src, getter, expected_type=None):
     # used when a method is needed
     # or list/number index retrieval
-    if not isinstance(getter, (list, tuple)):
-        getter = [getter]
-    for get in getter:
+    for get in wrap_as_list(getter):
         try:
             v = get(src)
         except (AttributeError, KeyError, TypeError, IndexError):
@@ -112,6 +140,11 @@ def int_or_none(v, default=None):
     except (ValueError, TypeError):
         return default
 
+def float_or_none(v, default=None):
+    try:
+        return float(v)
+    except (ValueError, TypeError):
+        return default
 
 def try_get_first_key(dictionary, default=None):
     try:
@@ -126,126 +159,48 @@ def try_get_first_value(dictionary, default=None):
     except Exception:
         return default
 
+
 def try_parse_json(text):
     try:
         return json.loads(text)
     except json.decoder.JSONDecodeError:
         return None
 
-def remove_prefixes(text, prefixes):
-    if not isinstance(prefixes, (list, tuple)):
-        prefixes = [prefixes]
+def wrap_as_list(item):
+    """Wraps an item in a list, if it is not already iterable
+
+    :param item: The item to wrap
+    :type item: object
+    :return: The wrapped item
+    :rtype: Union[list, tuple]
+    """
+    if not isinstance(item, (list, tuple)):
+        item = [item]
+    return item
 
-    for prefix in prefixes:
+def remove_prefixes(text, prefixes):
+    for prefix in wrap_as_list(prefixes):
         if text.startswith(prefix):
             text = text[len(prefix):]
-
     return text
 
 
 def remove_suffixes(text, suffixes):
-    if not isinstance(suffixes, (list, tuple)):
-        suffixes = [suffixes]
-
-    for suffix in suffixes:
+    for suffix in wrap_as_list(suffixes):
         if text.endswith(suffix):
             text = text[0:-len(suffix):]
-
     return text
 
 
 def update_dict_without_overwrite(original, new):
     original.update({key: new[key] for key in new if key not in original})
+    return original
 
 
 def camel_case_split(word):
     return '_'.join(re.findall(r'[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)', word)).lower()
 
 
-def supports_colour():
-    """
-    Return True if the running system's terminal supports colour,
-    and False otherwise.
-    """
-    def vt_codes_enabled_in_windows_registry():
-        """
-        Check the Windows Registry to see if VT code handling has been enabled
-        by default, see https://superuser.com/a/1300251/447564.
-        """
-        try:
-            # winreg is only available on Windows.
-            import winreg
-        except ImportError:
-            return False
-        else:
-            reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Console')
-            try:
-                reg_key_value, _ = winreg.QueryValueEx(
-                    reg_key, 'VirtualTerminalLevel')
-            except FileNotFoundError:
-                return False
-            else:
-                return reg_key_value == 1
-
-    # isatty is not always implemented, #6223.
-    is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
-
-    return is_a_tty and (
-        # Windows Terminal supports VT codes.
-        # Microsoft Visual Studio Code's built-in terminal supports colours.
-
-        (sys.platform != 'win32') or ('ANSICON' in os.environ) or ('WT_SESSION' in os.environ) or (
-            os.environ.get('TERM_PROGRAM') == 'vscode') or (vt_codes_enabled_in_windows_registry())
-    )
-
-
-if supports_colour():
-    handler = colorlog.StreamHandler()
-    handler.setFormatter(colorlog.ColoredFormatter(
-        '[%(log_color)s%(levelname)s%(reset)s] %(message)s',
-        log_colors={
-            'DEBUG': 'cyan',
-            'INFO': 'green',
-            'WARNING': 'yellow',
-            'ERROR': 'red',
-            'CRITICAL': 'bold_red',
-        })
-    )
-    logger = colorlog.getLogger()  # 'root'
-else:  # fallback support
-    import logging
-    handler = logging.StreamHandler()
-    handler.setFormatter(logging.Formatter('[%(levelname)s] %(message)s'))
-    logger = logging.getLogger()
-
-
-logger.addHandler(handler)
-
-
-def pause(text='Press Enter to continue...'):
-    input(text)
-
-
-def set_log_level(level):
-    logger.setLevel(level.upper())
-
-
-def get_logger():
-    return logger
-
-
-def log(level, items, to_pause=False):
-    logger_at_level = getattr(logger, level, None)
-    if logger_at_level:
-        if not isinstance(items, (tuple, list)):
-            items = [items]
-        for item in items:
-            logger_at_level(item)
-
-        if to_pause:
-            pause()
-
-
 def replace_with_underscores(text, sep='-'):
     return text.replace(sep, '_')
 
@@ -379,8 +334,8 @@ def safe_print(*objects, sep=' ', end='\n', out=None, encoding=None, flush=False
     """
     Ensure printing to standard output can be done safely (especially on Windows).
     There are usually issues with printing emojis and non utf-8 characters.
-
     """
+
     output_string = sep.join(map(lambda x: str(x), objects)) + end
 
     if out is None:
@@ -410,6 +365,9 @@ def nested_update(d, u):
     return d
 
 
+def pause(text='Press Enter to continue...'):
+    input(text)
+
 # Inspired by https://github.com/hero24/TimedInput/
 
 
@@ -503,10 +461,6 @@ def __next__(self):
             next_item = next(self.generator)
             self.reset_inactivity_timer()
             return next_item
-        except StopIteration as e:
-            # No more items to get. Temporarily save exception so that
-            # we can close the timers before exiting
-            to_raise = e
 
         except KeyboardInterrupt as e:
 
@@ -531,6 +485,12 @@ def __next__(self):
             else:  # both timers are still active, user sent a keyboard interrupt
                 to_raise = e
 
+        except Exception as e:
+            # Some other error. Always propogate.
+            # If e is StopIteration, there are no more items to get.
+            # We can close the timers before exiting
+            to_raise = e
+
         if to_raise:  # Something happened which will cause the generator to exit, cancel timers
             for timer in set_timers:
                 timer.cancel()
@@ -549,7 +509,7 @@ def timed_input(timeout=None, prompt='', default=None, *args, **kwargs):
         return TimedInput(timeout, prompt, default, *args, **kwargs).read()
 
 
-def interruptable_sleep(secs, poll_time=0.1):
+def interruptible_sleep(secs, poll_time=0.1):
     start_time = time.time()
 
     while time.time() - start_time <= secs:
@@ -561,7 +521,5 @@ def get_default_args(func):
     return {
         k: v.default
         for k, v in signature.parameters.items()
-        # if k != 'self'
         if v.default is not inspect.Parameter.empty
     }
-    # TODO get required args
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..12c0f01
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,22 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+SOURCEDIR     = .
+BUILDDIR      = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help clean Makefile
+
+clean:
+	-rm -rf $(BUILDDIR)/*
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+html:
+	$(SPHINXBUILD) -W --keep-going "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/README.rst b/docs/README.rst
new file mode 100644
index 0000000..af68b25
--- /dev/null
+++ b/docs/README.rst
@@ -0,0 +1,164 @@
+..
+    TODO
+    - temp move ... move back to root
+    - auto-generate using other rst files
+
+***************
+Chat Downloader
+***************
+
+.. image:: https://img.shields.io/pypi/pyversions/chat-downloader
+   :target: https://pypi.org/project/chat-downloader
+   :alt: Python
+.. image:: https://img.shields.io/pypi/v/chat-downloader.svg
+   :target: https://pypi.org/project/chat-downloader
+   :alt: PyPI version
+.. image:: https://pepy.tech/badge/chat-downloader
+   :target: https://pypi.org/project/chat-downloader
+   :alt: Downloads
+.. image:: https://img.shields.io/github/license/xenova/chat-downloader
+  :target: https://github.com/xenova/chat-downloader/blob/master/LICENSE
+  :alt: License
+
+..
+    [![PyPI Downloads](https://img.shields.io/pypi/dm/chat-downloader)](https://pypi.org/project/chat-downloader)
+    [![GitHub issues](https://img.shields.io/github/issues/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
+    [![GitHub forks](https://img.shields.io/github/forks/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
+    [![GitHub stars](https://img.shields.io/github/stars/xenova/chat-downloader)](https://badge.fury.io/py/chat-downloader)
+    [![Downloads](https://img.shields.io/github/downloads/xenova/chat-downloader/total.svg)](https://github.com/xenova/chat-downloader/releases)
+
+`Chat Downloader`_ is a simple tool used to retrieve chat messages from livestreams,
+videos, clips and past broadcasts. No authentication needed!
+
+.. _Chat Downloader: https://github.com/xenova/chat-downloader
+
+############
+Installation
+############
+
+This tool is distributed on PyPI_ and can be installed with ``pip``:
+
+.. _PyPI: https://pypi.org/project/chat-downloader/
+
+.. code:: console
+
+   $ pip install chat-downloader
+
+To update to the latest version, add the ``--upgrade`` flag to the above command.
+
+Alternatively, the tool can be installed with ``git``:
+
+.. code:: console
+
+   $ git clone https://github.com/xenova/chat-downloader.git
+   $ cd chat-downloader
+   $ python setup.py install
+
+
+#####
+Usage
+#####
+
+
+Command line
+------------
+
+.. program-output:: cd .. && python -m chat_downloader -h | awk '{print} /url/ {exit}'
+    :shell:
+
+For example, to save messages from a livestream to a JSON file, you can use:
+
+.. code:: console
+
+   $ chat_downloader https://www.youtube.com/watch?v=5qap5aO4i9A --output chat.json
+
+
+
+For a description of these options, as well as advanced command line use-cases and examples, consult the :ref:`Command Line Usage` page.
+
+
+Python
+------
+
+.. code:: python
+
+   from chat_downloader import ChatDownloader
+
+   url = 'https://www.youtube.com/watch?v=5qap5aO4i9A'
+   chat = ChatDownloader().get_chat(url)       # create a generator
+   for message in chat:                        # iterate over messages
+       chat.print_formatted(message)           # print the formatted message
+
+
+For advanced python use-cases and examples, consult the :ref:`Python Documentation`.
+
+
+##########
+Chat Items
+##########
+
+Chat items/messages are parsed into JSON objects (a.k.a. dictionaries) and should follow a format similar to this:
+
+.. code-block::
+
+    {
+        ...
+        "message_id": "xxxxxxxxxx",
+        "message": "actual message goes here",
+        "message_type": "text_message",
+        "timestamp": 1613761152565924,
+        "time_in_seconds": 1234.56,
+        "time_text": "20:34",
+        "author": {
+            "id": "UCxxxxxxxxxxxxxxxxxxxxxxx",
+            "name": "username_of_sender",
+            "images": [
+                ...
+            ],
+            "badges": [
+                ...
+            ]
+        },
+        ...
+    }
+
+
+For an extensive, documented list of included fields, consult the :ref:`Chat Item Fields` page.
+
+##########################
+Frequently Asked Questions
+##########################
+
+*Coming soon*
+
+######
+Issues
+######
+
+Found a bug or have a suggestion? File an issue `here`_. To assist the
+developers in fixing the issue, please follow the issue template as
+closely as possible.
+
+.. _here: https://github.com/xenova/chat-downloader/issues/new/choose
+
+
+############
+Contributing
+############
+
+If you would like to help improve the tool, you'll find more
+information on contributing in our :ref:`Contributing Guide`.
+
+
+################
+Supported sites:
+################
+
+-  YouTube.com - Livestreams, past broadcasts and premieres.
+-  Twitch.tv - Livestreams, past broadcasts and clips.
+-  Facebook.com (currently in development) - Livestreams and past
+   broadcasts.
+
+.. _Chat Item Wiki: https://github.com/xenova/chat-downloader/wiki/Item-Template
+.. _Command Line Wiki: https://github.com/xenova/chat-downloader/wiki/Command-Line-Usage
+.. _Python Wiki: https://github.com/xenova/chat-downloader/wiki/Python-Documentation
diff --git a/docs/_static/theme_overrides.css b/docs/_static/theme_overrides.css
new file mode 100644
index 0000000..aad8245
--- /dev/null
+++ b/docs/_static/theme_overrides.css
@@ -0,0 +1,10 @@
+/* override table width restrictions */
+.wy-table-responsive table td, .wy-table-responsive table th {
+    /* !important prevents the common CSS stylesheets from
+       overriding this as on RTD they are loaded after this stylesheet */
+    white-space: normal !important;
+}
+
+.wy-table-responsive {
+    overflow: visible !important;
+}
diff --git a/docs/changelog.rst b/docs/changelog.rst
new file mode 100644
index 0000000..90c394d
--- /dev/null
+++ b/docs/changelog.rst
@@ -0,0 +1,62 @@
+*********
+Changelog
+*********
+
+master
+======
+
+v0.0.4
+======
+
+:Date: 11 February 2021
+
+Sites
+-----
+
+* Use ``_NAME`` attribute and improve class structure
+* [YouTube] Unpack value returned from ``parse_runs`` (Fixes #59)
+* [Twitch] Move ``is_moderator``, ``is_subscriber`` and ``is_turbo`` to author dictionary
+
+
+v0.0.3
+======
+
+:Date: 10 February 2021
+
+Core
+----
+
+* Allow reusing of sessions
+
+Sites
+-----
+
+* Improved remapping (more advanced)
+* YouTube and Twitch: Parse emotes to get emote id and URLs
+
+Testing
+-------
+
+* Improved unit testing with pytest
+
+Workflows
+---------
+
+* CI with GitHub actions
+* Automatic distribution with twine on release
+
+
+v0.0.2
+======
+
+:Date: 3 February 2021
+
+* Ensure mutual exclusion for message groups and types
+
+
+v0.0.1
+======
+
+:Date: 2 February 2021
+
+* Initial release
diff --git a/docs/cli.rst b/docs/cli.rst
new file mode 100644
index 0000000..6b24233
--- /dev/null
+++ b/docs/cli.rst
@@ -0,0 +1,43 @@
+Command Line Usage
+==================
+
+
+Overview
+--------
+
+A full list of command line arguments can be obtained by running the help command:
+
+.. code:: console
+
+   $ chat_downloader -h
+
+The output of which is as follows:
+
+.. program-output:: cd .. && python -m chat_downloader -h
+    :shell:
+
+Examples
+--------
+
+#. Message groups and types
+
+   Options are specified by a space- or comma-separated list. If you specify more than one item, enclose the argument in quotation marks.
+
+   - Using message groups:
+
+   .. code:: console
+
+       $ chat_downloader https://www.youtube.com/watch?v=n5aQeLwwEns --message_groups "messages superchat"
+
+   -  Using message types:
+
+   .. code:: console
+
+       $ chat_downloader https://www.youtube.com/watch?v=n5aQeLwwEns --message_types membership_item
+
+
+#. Output to file
+
+.. code:: console
+
+   $ chat_downloader https://www.youtube.com/watch?v=n5aQeLwwEns --output chat.json
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..559eef2
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,206 @@
+# -*- coding: utf-8 -*-
+#
+# Configuration file for the Sphinx documentation builder.
+#
+# This file does only contain a selection of the most common options. For a
+# full list see the documentation:
+# http://www.sphinx-doc.org/en/master/config
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import sphinx_rtd_theme
+import os
+import sys
+sys.path.insert(0, os.path.abspath('..'))
+
+import sphinx_rtd_theme
+
+
+# -- Project information -----------------------------------------------------
+
+# Get metadata without importing the package
+with open(os.path.abspath('../chat_downloader/metadata.py')) as metadata_file:
+    exec(metadata_file.read())
+    metadata = locals()
+
+project = metadata['__title__']
+copyright = metadata['__copyright__']
+author = metadata['__author__']
+summary = metadata['__summary__']
+
+# The short X.Y version
+version = metadata['__version__']
+# The full version, including alpha/beta/rc tags
+release = metadata['__version__']
+
+
+# -- General configuration ---------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+
+
+extensions = [
+    'sphinx_rtd_theme',
+    'sphinx.ext.autodoc',
+    'sphinxcontrib.programoutput',
+    'sphinx.ext.autosectionlabel'
+    # 'm2r'
+]
+# suppress_warnings = ['autosectionlabel.*']
+
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = None
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = None
+
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'sphinx_rtd_theme'  # alabaster
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# Custom sidebar templates, must be a dictionary that maps document names
+# to template names.
+#
+# The default sidebars (for documents that don't match any pattern) are
+# defined by theme itself.  Builtin themes are using these templates by
+# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
+# 'searchbox.html']``.
+#
+# html_sidebars = {}
+
+html_show_sourcelink = False
+
+
+# -- Options for HTMLHelp output ---------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'chat-downloaderdoc'
+
+
+# -- Options for LaTeX output ------------------------------------------------
+
+latex_elements = {
+    # The paper size ('letterpaper' or 'a4paper').
+    #
+    # 'papersize': 'letterpaper',
+
+    # The font size ('10pt', '11pt' or '12pt').
+    #
+    # 'pointsize': '10pt',
+
+    # Additional stuff for the LaTeX preamble.
+    #
+    # 'preamble': '',
+
+    # Latex figure (float) alignment
+    #
+    # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+    (master_doc, 'chat-downloader.tex', 'chat-downloader Documentation',
+     author, 'manual'),
+]
+
+
+# -- Options for manual page output ------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    (master_doc, 'chat-downloader', 'chat-downloader Documentation',
+     [author], 1)
+]
+
+
+# -- Options for Texinfo output ----------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    (master_doc, 'chat-downloader', 'chat-downloader Documentation',
+     author, 'chat-downloader', summary,
+     'Miscellaneous'),
+]
+
+
+# -- Options for Epub output -------------------------------------------------
+
+# Bibliographic Dublin Core info.
+epub_title = project
+
+# The unique identifier of the text. This can be a ISBN number
+# or the project homepage.
+#
+# epub_identifier = ''
+
+# A unique identification for the text.
+#
+# epub_uid = ''
+
+# A list of files that should not be packed into the epub file.
+epub_exclude_files = ['search.html']
+
+
+# -- Extension configuration -------------------------------------------------
+
+def skip(app, what, name, obj, would_skip, options):
+    if name == '__init__':
+        return False
+    return would_skip
+
+def setup(app):
+    app.connect('autodoc-skip-member', skip)
+    app.add_stylesheet('theme_overrides.css')
diff --git a/docs/contributing.rst b/docs/contributing.rst
new file mode 100644
index 0000000..d083e94
--- /dev/null
+++ b/docs/contributing.rst
@@ -0,0 +1,149 @@
+Contributing Guide
+==================
+
+
+Developers
+~~~~~~~~~~
+
+The following section outlines the basic procedure for people who
+want to assist in development.
+
+Add features, fix bugs or write documentation
+---------------------------------------------
+
+#. `Fork this repository`_.
+
+#. Clone the forked repository with:
+
+   .. code:: console
+
+       $ git clone git@github.com:YOUR_GITHUB_USERNAME/chat-downloader.git
+
+
+#. Start a new branch with:
+
+   .. code:: console
+
+       $ cd chat-downloader
+       $ git checkout -b name
+
+
+#. Set up your environment by installing the developer dependencies:
+
+   .. code:: console
+
+       $ pip install -e .[dev]
+
+#. Make changes. See below for common changes to be made.
+
+#. Test your changes with pytest:
+
+   Depending on the changes made, run the appropriate tests to ensure
+   everything still works as intended.
+
+   #. To run all tests:
+
+      .. code:: console
+
+          $ pytest -v
+
+   #. To run tests for all sites:
+
+      .. code:: console
+
+          $ pytest -v tests/test_chat_downloader.py::TestChatDownloader
+
+
+   #. To run the tests for a specific site:
+
+      .. code:: console
+
+          $ pytest -v tests/test_chat_downloader.py::TestChatDownloader::test_YourChatDownloader_TestNumber
+
+      e.g.
+
+      .. code:: console
+
+          $ pytest -v tests/test_chat_downloader.py::TestChatDownloader::test_YouTubeChatDownloader_1
+
+#. Make sure your code follows our coding conventions and check the code
+   with `flake8`_:
+
+   .. code:: console
+
+       $ flake8 path/to/code/to/check.py
+
+
+   While we encourage users to follow flake8 conventions, some warnings
+   are not very important and can be ignored, e.g:
+
+   .. code:: console
+
+       $ flake8 path/to/code/to/check.py --ignore E501,W503,W504
+
+#. When the tests pass, `add`_ the new files and `commit`_ them and
+   `push`_ the result, like this:
+
+   .. code:: console
+
+       $ git add path/to/code.py
+       $ git commit -m 'message'
+       $ git push origin name
+
+
+#. Finally, `create a pull request`_. We'll then review and merge it.
+
+Starting templates
+------------------
+
+When adding new features, we encourage developers to use these templates
+as starting points. This helps ensure consistency across the codebase.
+
+New site
+^^^^^^^^
+
+*Coming soon*
+
+
+
+
+.. _Fork this repository: https://github.com/xenova/chat-downloader/fork
+.. _flake8: https://flake8.pycqa.org/en/latest/
+.. _add: https://git-scm.com/docs/git-add
+.. _commit: https://git-scm.com/docs/git-commit
+.. _push: https://git-scm.com/docs/git-push
+.. _create a pull request: https://help.github.com/articles/creating-a-pull-request
+
+
+
+Testing
+~~~~~~~
+
+If you are unable to write code but still wish to assist, we encourage
+users to run commands with the ``--testing`` flag included. This will
+print debugging messages and pause once something unexpected happens
+(e.g. when an unknown item is being parsed). If something happens,
+please raise an issue and we will fix it or add support for it as
+soon as possible! For example:
+
+.. code:: console
+
+    $ chat_downloader https://www.youtube.com/watch?v=5qap5aO4i9A --testing
+
+Some extractors use undocumented endpoints and as a result, users may
+encounter items which will not be parsed correctly. Increased testing
+will help find these items and ultimately improve functionality of the
+software for other users. Note that this will not affect any output you
+write to files (using ``--output``).
+
+
+Sponsor
+~~~~~~~
+
+`Chat Downloader`_ has always and will always be free. If you are feeling
+generous, donations are always appreciated!
+
+* https://ko-fi.com/xenova
+* https://www.buymeacoffee.com/xenova
+
+.. _Chat Downloader: https://github.com/xenova/chat-downloader
diff --git a/docs/generate_readme.py b/docs/generate_readme.py
new file mode 100644
index 0000000..c70df1a
--- /dev/null
+++ b/docs/generate_readme.py
@@ -0,0 +1,47 @@
+import re
+import os
+import subprocess
+
+# Prepare README.rst for GitHub
+# This includes:
+#  - Generating command line output
+#  - Fixing links
+
+readme_path = 'README.rst'
+if not os.path.exists(readme_path):
+    print('Error. This must be run from the docs directory')
+    exit()
+
+readme = open(readme_path).read()
+cmd_regex = r'(\.+\s+program-output::\s+(.+)\s+:shell:)'
+padding = ' '*4
+def replace_cmd_output(match):
+    cmd = match.group(2)
+    output = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True).communicate()
+    text = output[0].decode()
+
+
+    return '.. code:: console\n\n' + ''.join(map(lambda x: padding + x, text.splitlines(True)))
+
+substitution = re.sub(cmd_regex, replace_cmd_output, readme)
+
+
+relative_link_regex = r'<a class="reference internal" href="(.*)"><span class="std std-ref">(.*)</span></a>'
+# Get generated links
+readme_html = open('_build/README.html').read()
+
+relative_link_dict = {name: link for link, name in re.findall(relative_link_regex, readme_html)}
+
+BASE_READTHEDOCS_URL = 'https://chat-downloader.readthedocs.io/en/latest/'
+# BASE_READTHEDOCS_URL = 'https://chat-downloader.readthedocs.io/en/docs/' # Testing
+
+
+reference_regex = r':ref:`(.*)`'
+def replace_reference_tag(match):
+    text = match.group(1)
+    url = relative_link_dict.get(text)
+    return '`{} <{}{}>`_'.format(text, BASE_READTHEDOCS_URL, url)
+
+substitution = re.sub(reference_regex, replace_reference_tag, substitution)
+
+print(substitution)
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..c2c5be9
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,18 @@
+
+.. include:: README.rst
+
+********
+Overview
+********
+
+.. toctree::
+   :maxdepth: 1
+
+   cli
+   source/index
+   items
+   options
+   contributing
+   changelog
+
+
diff --git a/docs/items.rst b/docs/items.rst
new file mode 100644
index 0000000..f9f2551
--- /dev/null
+++ b/docs/items.rst
@@ -0,0 +1,245 @@
+Chat Item Fields
+================
+
+First of all, each chat item is a dictionary. The following tables
+list and provide explanations for the various fields that a chat
+item may possess.
+
+
+If you find a field that is not listed below,
+please notify the developers by creating an issue, or adding
+the relevant documentation in a pull request.
+
+
+.. note::
+   It is recommended to treat every field listed below as optional.
+   While most items contain basic information such as `timestamp`,
+   `message` or `author`, it cannot be guaranteed that every item
+   will contain these fields.
+
+
+.. list-table:: Common fields
+   :widths: 20 10 70
+   :header-rows: 1
+
+   * - Field
+     - Type
+     - Description
+   * - timestamp
+     - float
+     - UNIX time (in microseconds) of when the message was sent.
+   * - message
+     - string
+     - Actual content/text of the chat item.
+   * - message_id
+     - string
+     - Identifier for the chat item.
+   * - message_type
+     - string
+     - Message type of the item.
+   * - author
+     - dictionary
+     - A dictionary containing information about the user who sent the message. For author fields, see :ref:`here <Author fields table>`.
+   * - time_in_seconds
+     - float
+     - The number of seconds after the video began, that the message was sent.  This is only present for replays/vods/clips (i.e. a video which is not live).
+   * - time_text
+     - string
+     - Human-readable format for `time_in_seconds`.
+
+Documentation for other (less common) fields can be found :ref:`here <Other fields table>`.
+
+.. _Author fields table:
+
+.. list-table:: Author fields
+   :widths: 20 10 70
+   :header-rows: 1
+
+   * - Field
+     - Type
+     - Description
+   * - name
+     - string
+     - The name of the author.
+   * - id
+     - string
+     - Idenfifier for the author.
+   * - display_name
+     - string
+     - The name of the author which is displayed to the viewer. This may be different to `name`.
+   * - short_name
+     - string
+     - A shortened version of the author's name.
+   * - type
+     - string
+     - Type of the author.
+   * - url
+     - string
+     - URL of the author's channel/page.
+   * - images
+     - list
+     - A list which contains different sizes of the author's profile picture. See :ref:`here <Image fields table>` for the fields that an image may have.
+   * - badges
+     - list
+     - A list of the author's badges. See :ref:`here <Badge fields table>` for the fields that a badge may have.
+   * - gender
+     - string
+     - Gender of the author.
+   * - is_banned
+     - boolean
+     - `True` if the user is banned, `False` otherwise.
+   * - is_bot
+     - boolean
+     - `True` if the user is a bot, `False` otherwise.
+   * - is_non_coworker
+     - boolean
+     - `True` if the user is not a coworker, `False` otherwise.
+   * - is_original_poster
+     - boolean
+     - `True` if the user is the original poster, `False` otherwise.
+   * - is_verified
+     - boolean
+     - `True` if the user is verified, `False` otherwise.
+
+.. _Image fields table:
+
+.. list-table:: Image fields
+   :widths: 20 10 70
+   :header-rows: 1
+
+   * - Field
+     - Type
+     - Description
+   * - url
+     - string
+     - The URL of the actual image
+   * - width
+     - integer
+     - The width of the image
+   * - height
+     - integer
+     - The height of the image
+   * - image_id
+     - string
+     - A identifier for the image, usually of the form: {width}x{height}
+
+.. _Badge fields table:
+
+.. list-table:: Badge fields
+   :widths: 20 10 70
+   :header-rows: 1
+
+   * - Field
+     - Type
+     - Description
+   * - title
+     - string
+     - The title of the badge.
+   * - id
+     - string
+     - Identifier for the badge.
+   * - name
+     - string
+     - Name of the badge.
+   * - version
+     - integer
+     - Version of the badge.
+   * - icon_name
+     - string
+     - Name of the badge icon.
+   * - icons
+     - list
+     - A list of images for the badge icons. See :ref:`here <Image fields table>` for potential fields.
+   * - description
+     - string
+     - The description of the badge.
+   * - alternative_title
+     - string
+     - Alternative title of the badge.
+   * - click_action
+     - string
+     - Action to perform if the badge is clicked.
+   * - click_url
+     - string
+     - URL to visit if the badge is clicked.
+
+.. _Other fields table:
+
+.. list-table:: Other fields
+   :widths: 20 10 70
+   :header-rows: 1
+
+   * - Field
+     - Type
+     - Description
+   * - amount
+     - float
+     - The amount of money that was sent with the message.
+   * - sub_message
+     - string
+     - Additional text of the message.
+   * - action_type
+     - string
+     - Action type of the item.
+   * - tooltip
+     - string
+     - Text to be displayed when hovering over the message.
+   * - icon
+     - string
+     - Icon associated with the message.
+   * - target_message_id
+     - string
+     - The identifier for a message which this message references.
+   * - action
+     - string
+     - The action of the message.
+   * - viewer_is_creator
+     - boolean
+     - Whether the viewer is the creator or not.
+   * - sticker_images
+     - list
+     - A list which contains different sizes of the sticker image. See :ref:`here <Image fields table>` for image fields.
+   * - sponsor_icons
+     - list
+     - A list which contains different sizes of the sponsor image. See :ref:`here <Image fields table>` for image fields.
+   * - ticker_icons
+     - list
+     - A list which contains different sizes of the ticker image. See :ref:`here <Image fields table>` for image fields.
+   * - ticker_duration
+     - float
+     - How long the ticker message is displayed for.
+   * - field
+     - type
+     - description
+   * - field
+     - type
+     - description
+   * - field
+     - type
+     - description
+   * - field
+     - type
+     - description
+   * - field
+     - type
+     - description
+   * - field
+     - type
+     - description
+
+
+The following fields indicate HEX colour information for the message:
+
+author_name_text_colour
+timestamp_colour
+body_background_colour
+header_text_colour
+header_background_colour
+body_text_colour
+background_colour
+money_chip_text_colour
+money_chip_background_colour
+start_background_colour
+amount_text_colour
+end_background_colour
+detail_text_colour
diff --git a/docs/options.rst b/docs/options.rst
new file mode 100644
index 0000000..69959e9
--- /dev/null
+++ b/docs/options.rst
@@ -0,0 +1,24 @@
+General Options
+===============
+
+This page provides descriptions for the different available options. For the most part, there are two types of
+
+For implementation details, see the corresponding Command-line or Python usage guide.
+
+Message groups and types
+------------------------
+
+There are two ways to specify what types of chat messages will be included:
+
+1. Message types - The value of `message_type` for the chat object
+
+2. Message groups - A site-specific collection of message types. One message group contains a list of message types, and each site has a number of message groups which can be used. See below for message groups (and their associated message types) for the supported sites.
+
+Please note that these two options are **mutually exclusive**. So, you may only specify one at a time.
+
+For examples, see Command-line or Python Usage
+
+
+
+Outputting to a file
+--------------------
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..171f255
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,5 @@
+# Documentation Related
+sphinxcontrib-programoutput
+
+# Program related
+chat-downloader
diff --git a/docs/source/chat_downloader.rst b/docs/source/chat_downloader.rst
new file mode 100644
index 0000000..2093288
--- /dev/null
+++ b/docs/source/chat_downloader.rst
@@ -0,0 +1,36 @@
+
+.. py:module:: chat_downloader
+.. py:currentmodule:: chat_downloader
+
+
+:py:class:`ChatDownloader`
+**************************
+
+
+.. autoclass:: chat_downloader.ChatDownloader
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+Examples
+--------
+
+#. Message groups and types
+
+   Options are specified as a list of strings.
+
+   .. code:: python
+
+       from chat_downloader import ChatDownloader
+
+       downloader = ChatDownloader()
+
+       url = 'https://www.youtube.com/watch?v=n5aQeLwwEns'
+
+       # 1. Using message groups:
+       groups_example = downloader.get_chat(url, message_groups=['messages', 'superchat'])
+
+       # 2. Using message types:
+       types_example = downloader.get_chat(url, message_types=['membership_item'])
+
+#. 2
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..27c6ef2
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,9 @@
+Python Documentation
+====================
+
+.. toctree::
+    :maxdepth: 2
+
+
+    chat_downloader
+    sites
diff --git a/docs/source/sites.rst b/docs/source/sites.rst
new file mode 100644
index 0000000..6e252ea
--- /dev/null
+++ b/docs/source/sites.rst
@@ -0,0 +1,41 @@
+
+.. py:module:: chat_downloader.sites
+.. py:currentmodule:: chat_downloader.sites
+
+:py:mod:`~chat_downloader.sites` Module
+=======================================
+
+
+:py:class:`YouTubeChatDownloader`
+*********************************
+
+.. autoclass:: chat_downloader.sites.YouTubeChatDownloader
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+:py:class:`TwitchChatDownloader`
+********************************
+
+.. autoclass:: chat_downloader.sites.TwitchChatDownloader
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+:py:class:`FacebookChatDownloader`
+**********************************
+
+.. autoclass:: chat_downloader.sites.FacebookChatDownloader
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+:py:class:`BaseChatDownloader`
+******************************
+.. autoclass:: chat_downloader.sites.BaseChatDownloader
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/setup.py b/setup.py
index 696850c..56b92a4 100644
--- a/setup.py
+++ b/setup.py
@@ -9,11 +9,11 @@
     exec(metadata_file.read())
     metadata = locals()
 
-with open('README.md') as readme_file:
+with open('README.rst') as readme_file:
     readme = readme_file.read()
 
-with open('HISTORY.md') as history_file:
-    history = history_file.read()
+# with open('HISTORY.rst') as history_file:
+#     history = history_file.read()
 
 requirements = [
     'requests',
@@ -30,18 +30,18 @@
     author_email=metadata['__email__'],
     url=metadata['__url__'],
     version=metadata['__version__'],
-    python_requires='>=3.5',
+    python_requires='>=3.6',
     classifiers=[
         'Development Status :: 2 - Pre-Alpha',
         'Intended Audience :: Developers',
         'License :: OSI Approved :: MIT License',
         'Natural Language :: English',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.5',
         'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
         'Programming Language :: Python :: 3.9',
+        'Programming Language :: Python :: 3.10',
         'Operating System :: OS Independent',
     ],
     description=metadata['__summary__'],
@@ -57,13 +57,15 @@
             'twine',
             'wheel',
             'tox',
+            'pytest',
             'sphinx',
-            'pytest'
+            'sphinx-rtd-theme',
+            'sphinxcontrib-programoutput'
         ]
     },
     license='MIT license',
-    long_description=readme + '\n\n' + history,
-    long_description_content_type='text/markdown',
+    long_description=readme, # + '\n\n' + history,
+    long_description_content_type='text/x-rst',
     include_package_data=True,
     keywords='python chat downloader youtube twitch',
     name='chat-downloader',


Test patch:
diff --git a/tests/test_generators.py b/tests/test_generators.py
index 546c1ac..9696498 100644
--- a/tests/test_generators.py
+++ b/tests/test_generators.py
@@ -6,7 +6,7 @@
 # Allow direct execution
 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))  # noqa
 
-from chat_downloader import run
+from chat_downloader import (run, ChatDownloader)
 from chat_downloader.sites import get_all_sites
 
 args = {
@@ -39,9 +39,6 @@
     parser.add_argument(
         '--max_tests_per_site', default=args['max_tests_per_site'], type=int, help='The maximum number of tests that any site can generate.')
 
-
-
-
     parser.add_argument(
         '--livestream_limit', default=args['livestream_limit'], type=int, help='The maximum number of livestreams to generate.')
     parser.add_argument(
@@ -54,6 +51,7 @@
 except ValueError:
     pass
 
+
 class TestURLGenerators(unittest.TestCase):
     """
     Test case generator for all sites. For each site, URLs are generated with the class'
@@ -70,23 +68,22 @@ def test_template(self):
             'timeout': args['timeout'],
             'exit_on_debug': True,
             'quiet': True,
-            'interruptible': True,
         }
 
         print('\r ➤ ', url)
-        run(**params)
+        run(propagate_interrupt=True, **params)
 
     return test_template
 
 
+downloader = ChatDownloader()
+
 print('Arguments:', args)
 for site in get_all_sites():
-    url_generator = getattr(site, 'generate_urls', None)
-
     try:
         print('Generating', args['max_tests_per_site'],
               'tests for', site.__name__)
-        urls = itertools.islice(url_generator(), args['max_tests_per_site'])
+        urls = itertools.islice(downloader.create_session(site).generate_urls(), args['max_tests_per_site'])
         list_of_urls = list(urls)
         num_tests = len(list_of_urls)
         padding = len(str(num_tests))