Repository: learningequality/ricecooker (language: python)

Issue:
Matplotlib Dependency Error
* ricecooker version:
* Python version: 3.8.1
* Operating System: Windows x64

### Description

Describe what you were trying to get done.
Tell us what happened, what went wrong, and what you expected to happen.

### What I Did
I attempted pip install ricecooker 
```
Paste the command(s) you ran and the output.
If there was a crash, please include the traceback here.
```
Collecting matplotlib==2.2.3
  Using cached matplotlib-2.2.3.tar.gz (36.8 MB)
    ERROR: Command errored out with exit status 1:
     command: 'c:\users\15707\appdata\local\programs\python\python38\python.exe' -c 'import sys, setuptools, tokenize; sys.argv[0] = '"'"'C:\\Users\\15707\\AppData\\Local\\Temp\\pip-install-f0d_au4_\\matplotlib\\setup.py'"'"'; __file__='"'"'C:\\Users\\15707\\AppData\\Local\\Temp\\pip-install-f0d_au4_\\matplotlib\\setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' egg_info --egg-base 'C:\Users\15707\AppData\Local\Temp\pip-pip-egg-info-10jj61l4'
         cwd: C:\Users\15707\AppData\Local\Temp\pip-install-f0d_au4_\matplotlib\
    Complete output (61 lines):
    ============================================================================
    Edit setup.cfg to change the build options

    BUILDING MATPLOTLIB
                matplotlib: yes [2.2.3]
                    python: yes [3.8.1 (tags/v3.8.1:1b293b6, Dec 18 2019,
                            23:11:46) [MSC v.1916 64 bit (AMD64)]]
                  platform: yes [win32]

    REQUIRED DEPENDENCIES AND EXTENSIONS
                     numpy: yes [version 1.18.4]
          install_requires: yes [handled by setuptools]
                    libagg: yes [pkg-config information for 'libagg' could not
                            be found. Using local copy.]
                  freetype: no  [The C/C++ header for freetype
                            (freetype2\ft2build.h) could not be found.  You may
                            need to install the development package.]
                       png: no  [The C/C++ header for png (png.h) could not be
                            found.  You may need to install the development
                            package.]
                     qhull: yes [pkg-config information for 'libqhull' could not
                            be found. Using local copy.]

    OPTIONAL SUBPACKAGES
               sample_data: yes [installing]
                  toolkits: yes [installing]
                     tests: no  [skipping due to configuration]
            toolkits_tests: no  [skipping due to configuration]

    OPTIONAL BACKEND EXTENSIONS
                    macosx: no  [Mac OS-X only]
                    qt5agg: no  [PySide2 not found; PyQt5 not found]
                    qt4agg: no  [PySide not found; PyQt4 not found]
                   gtk3agg: no  [Requires pygobject to be installed.]
                 gtk3cairo: no  [Requires cairocffi or pycairo to be installed.]
                    gtkagg: no  [Requires pygtk]
                     tkagg: yes [installing; run-time loading from Python Tcl /
                            Tk]
                     wxagg: no  [requires wxPython]
                       gtk: no  [Requires pygtk]
                       agg: yes [installing]
                     cairo: no  [cairocffi or pycairo not found]
                 windowing: yes [installing]

    OPTIONAL LATEX DEPENDENCIES
                    dvipng: no
               ghostscript: no
                     latex: no
                   pdftops: no

    OPTIONAL PACKAGE DATA
                      dlls: no  [skipping due to configuration]

    ============================================================================
                            * The following required packages can not be built:
                            * freetype, png
                            * Please check http://gnuwin32.sourceforge.net/packa
                            * ges/freetype.htm for instructions to install
                            * freetype
                            * Please check http://gnuwin32.sourceforge.net/packa
                            * ges/libpng.htm for instructions to install png
    ----------------------------------------
ERROR: Command errored out with exit status 1: python setup.py egg_info Check the logs for full command output.


Discussion / hints:

Fix patch:
diff --git a/examples/oldexamples/sample_program.py b/examples/oldexamples/sample_program.py
index 9d0239a5..fe78f384 100755
--- a/examples/oldexamples/sample_program.py
+++ b/examples/oldexamples/sample_program.py
@@ -11,7 +11,7 @@
 from ricecooker.classes.licenses import get_license
 from ricecooker.exceptions import UnknownContentKindError, UnknownFileTypeError, UnknownQuestionTypeError, InvalidFormatException, raise_for_invalid_channel
 from le_utils.constants import content_kinds,file_formats, format_presets, licenses, exercises, languages
-from pressurecooker.encodings import get_base64_encoding
+from ricecooker.utils.encodings import get_base64_encoding
 
 
 # CHANNEL SETTINGS
diff --git a/ricecooker/chefs.py b/ricecooker/chefs.py
index 379494ea..92d36976 100644
--- a/ricecooker/chefs.py
+++ b/ricecooker/chefs.py
@@ -30,7 +30,7 @@
 from .utils.tokens import get_content_curation_token
 from .utils.youtube import YouTubeVideoUtils, YouTubePlaylistUtils
 
-from pressurecooker.images import convert_image
+from ricecooker.utils.images import convert_image
 
 
 # SUSHI CHEF BASE CLASS
diff --git a/ricecooker/classes/files.py b/ricecooker/classes/files.py
index 068502c7..50092458 100644
--- a/ricecooker/classes/files.py
+++ b/ricecooker/classes/files.py
@@ -6,8 +6,7 @@
 import os
 from PIL import Image
 import json
-import requests
-from requests.exceptions import MissingSchema, HTTPError, ConnectionError, InvalidURL, InvalidSchema
+from requests.exceptions import HTTPError, ConnectionError, InvalidURL, InvalidSchema
 import shutil
 from subprocess import CalledProcessError
 import tempfile
@@ -18,19 +17,18 @@
 
 from le_utils.constants import languages
 from le_utils.constants import file_formats, format_presets, exercises
-from pressurecooker.encodings import get_base64_encoding, write_base64_to_file
-from pressurecooker.images import create_image_from_pdf_page
-from pressurecooker.images import create_image_from_epub
-from pressurecooker.images import create_image_from_zip
-from pressurecooker.videos import extract_thumbnail_from_video
-from pressurecooker.images import create_waveform_image
-from pressurecooker.images import create_tiled_image
-from pressurecooker.images import ThumbnailGenerationError
-from pressurecooker.subtitles import build_subtitle_converter_from_file
-from pressurecooker.subtitles import LANGUAGE_CODE_UNKNOWN
-from pressurecooker.subtitles import InvalidSubtitleFormatError, InvalidSubtitleLanguageError
-from pressurecooker.videos import guess_video_preset_by_resolution, compress_video, VideoCompressionError
-from pressurecooker.youtube import YouTubeResource
+from ricecooker.utils.encodings import get_base64_encoding, write_base64_to_file
+from ricecooker.utils.images import create_image_from_pdf_page
+from ricecooker.utils.images import create_image_from_epub
+from ricecooker.utils.images import create_image_from_zip
+from ricecooker.utils.videos import extract_thumbnail_from_video
+from ricecooker.utils.images import create_tiled_image
+from ricecooker.utils.images import ThumbnailGenerationError
+from ricecooker.utils.subtitles import build_subtitle_converter_from_file
+from ricecooker.utils.subtitles import LANGUAGE_CODE_UNKNOWN
+from ricecooker.utils.subtitles import InvalidSubtitleFormatError, InvalidSubtitleLanguageError
+from ricecooker.utils.videos import guess_video_preset_by_resolution, compress_video, VideoCompressionError
+from ricecooker.utils.youtube import YouTubeResource
 
 from .. import config
 from ..exceptions import UnknownFileTypeError
@@ -1002,15 +1000,6 @@ class ExtractedVideoThumbnailFile(ExtractedThumbnailFile):
     def extractor_fun(self, fpath_in, thumbpath_out, **kwargs):
         extract_thumbnail_from_video(fpath_in, thumbpath_out, **kwargs)
 
-
-class ExtractedAudioThumbnailFile(ExtractedThumbnailFile):
-    studio_cmap_options = {'name': 'BuPu', 'vmin': 0.3, 'vmax': 0.7, 'color': 'black'}
-    extractor_kwargs = {'colormap_options': studio_cmap_options}
-
-    def extractor_fun(self, fpath_in, thumbpath_out, **kwargs):
-        create_waveform_image(fpath_in, thumbpath_out, **kwargs)
-
-
 class TiledThumbnailFile(ThumbnailPresetMixin, File):
     allowed_formats = [file_formats.JPG, file_formats.JPEG, file_formats.PNG]
 
diff --git a/ricecooker/classes/nodes.py b/ricecooker/classes/nodes.py
index 0a7efe67..3a55589e 100644
--- a/ricecooker/classes/nodes.py
+++ b/ricecooker/classes/nodes.py
@@ -682,17 +682,6 @@ class AudioNode(ContentNode):
     kind = content_kinds.AUDIO
     required_file_format = file_formats.MP3
 
-    def generate_thumbnail(self):
-        from .files import AudioFile, ExtractedAudioThumbnailFile
-        audio_files = [f for f in self.files if isinstance(f, AudioFile)]
-        if audio_files:
-            audio_file = audio_files[0]
-            if audio_file.filename and not audio_file.error:
-                storage_path = config.get_storage_path(audio_file.filename)
-                return ExtractedAudioThumbnailFile(storage_path)
-        return None
-
-
     def validate(self):
         """ validate: Makes sure audio is valid
             Args: None
diff --git a/ricecooker/classes/questions.py b/ricecooker/classes/questions.py
index 242ee97e..70b13532 100644
--- a/ricecooker/classes/questions.py
+++ b/ricecooker/classes/questions.py
@@ -12,7 +12,7 @@
 from .. import config
 from ..exceptions import UnknownQuestionTypeError, InvalidQuestionException
 from .files import _ExerciseImageFile, _ExerciseGraphieFile, _ExerciseBase64ImageFile
-from pressurecooker.encodings import get_base64_encoding
+from ricecooker.utils.encodings import get_base64_encoding
 
 
 WEB_GRAPHIE_URL_REGEX = r'web\+graphie:(?P<rawpath>[^\)]+)'  # match web_graphie:{{path}}
diff --git a/ricecooker/utils/encodings.py b/ricecooker/utils/encodings.py
new file mode 100644
index 00000000..cefcae37
--- /dev/null
+++ b/ricecooker/utils/encodings.py
@@ -0,0 +1,39 @@
+import re
+import base64
+
+BASE64_REGEX_STR = r'data:image\/([A-Za-z]*);base64,((?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)*)'
+BASE64_REGEX = re.compile(BASE64_REGEX_STR, flags=re.IGNORECASE)
+
+
+def get_base64_encoding(text):
+    """ get_base64_encoding: Get the first base64 match or None
+        Args:
+            text (str): text to check for base64 encoding
+        Returns: First match in text
+    """
+    return BASE64_REGEX.search(text)
+
+def write_base64_to_file(encoding, fpath_out):
+    """ write_base64_to_file: Convert base64 image to file
+        Args:
+            encoding (str): base64 encoded string
+            fpath_out (str): path to file to write
+        Returns: None
+    """
+
+    encoding_match = get_base64_encoding(encoding)
+
+    assert encoding_match, "Error writing to file: Invalid base64 encoding"
+
+    with open(fpath_out, "wb") as target_file:
+        target_file.write(base64.decodebytes(encoding_match.group(2).encode('utf-8')))
+
+def encode_file_to_base64(fpath_in, prefix):
+    """ encode_file_to_base64: gets base64 encoding of file
+        Args:
+            fpath_in (str): path to file to encode
+            prefix (str): file data for encoding (e.g. 'data:image/png;base64,')
+        Returns: base64 encoding of file
+    """
+    with open(fpath_in, 'rb') as file_obj:
+        return prefix + base64.b64encode(file_obj.read()).decode('utf-8')
diff --git a/ricecooker/utils/images.py b/ricecooker/utils/images.py
new file mode 100644
index 00000000..3fccd630
--- /dev/null
+++ b/ricecooker/utils/images.py
@@ -0,0 +1,194 @@
+import os
+import zipfile
+import ebooklib
+import ebooklib.epub
+from io import BytesIO
+
+
+from pdf2image import convert_from_path
+from PIL import Image
+
+from .thumbscropping import scale_and_crop
+
+
+
+# SMARTCROP UTILS
+################################################################################
+
+THUMBNAIL_SIZE = (400, 225)  # 16:9 aspect ratio
+
+def scale_and_crop_thumbnail(image, size=THUMBNAIL_SIZE, crop="smart", **kwargs):
+    """
+    Scale and crop the PIL Image ``image`` to maximum dimensions of ``size``.
+    By default, ``crop`` is set to "smart" which will crop the image down to size
+    based on the entropy content of the pixels. The other options are:
+    * Use ``crop="0,0"`` to crop from the left and top edges
+    * Use ``crop=",0"`` to crop from the top edge.
+    Optional keyword arguments:
+    * ``zoom=X``: crop outer X% before starting
+    * ``target``: recenter here before cropping (default center ``(50, 50)``)
+    See the ``scale_and_crop`` docs in ``thumbscropping.py`` for more details.
+    """
+    return scale_and_crop(image, size, crop=crop, upscale=True, **kwargs)
+
+
+
+# THUMBNAILS FOR CONTENT KINDS
+################################################################################
+
+def create_image_from_epub(epubfile, fpath_out, crop=None):
+    """
+    Generate a thumbnail image from `epubfile` and save it to `fpath_out`.
+    Raises ThumbnailGenerationError if thumbnail extraction fails.
+    """
+    try:
+        book = ebooklib.epub.read_epub(epubfile)
+        # 1. try to get cover image from book metadata (content.opf)
+        cover_item = None
+        covers = book.get_metadata('http://www.idpf.org/2007/opf', 'cover')
+        if covers:
+            cover_tuple = covers[0] # ~= (None, {'name':'cover', 'content':'item1'})
+            cover_item_id = cover_tuple[1]['content']
+            for item in book.items:
+                if item.id == cover_item_id:
+                    cover_item = item
+        if cover_item:
+            image_data = BytesIO(cover_item.get_content())
+        else:
+            # 2. fallback to get first image in the ePub file
+            images = list(book.get_items_of_type(ebooklib.ITEM_IMAGE))
+            if not images:
+                raise ThumbnailGenerationError("ePub file {} contains no images.".format(epubfile))
+            # TODO: get largest image of the bunch
+            image_data = BytesIO(images[0].get_content())
+
+        # Save image_data to fpath_out
+        im = Image.open(image_data)
+        im = scale_and_crop_thumbnail(im, crop=crop)
+        im.save(fpath_out)
+    except Exception as e:
+        raise ThumbnailGenerationError("Fail on ePub {} {}".format(epubfile, e))
+
+
+def create_image_from_zip(htmlfile, fpath_out, crop="smart"):
+    """
+    Create an image from the html5 zip at htmlfile and write result to fpath_out.
+    Raises ThumbnailGenerationError if thumbnail extraction fails.
+    """
+    biggest_name = None
+    size = 0
+    try:
+        with zipfile.ZipFile(htmlfile, 'r') as zf:
+            # get the biggest (most pixels) image in the zip
+            image_exts = ['png', 'PNG', 'jpeg', 'JPEG', 'jpg', 'JPG']
+            for filename in zf.namelist():
+                _, dotext = os.path.splitext(filename)
+                ext = dotext[1:]
+                if ext in image_exts:
+                    with zf.open(filename) as fhandle:
+                        image_data = fhandle.read()
+                        with BytesIO(image_data) as bhandle:
+                            img = Image.open(bhandle)
+                            img_size = img.size[0] * img.size[1]
+                            if img_size > size:
+                                biggest_name = filename
+                                size = img_size
+            if biggest_name is None:
+                raise ThumbnailGenerationError("HTML5 zip file {} contains no images.".format(htmlfile))
+            with zf.open(biggest_name) as fhandle:
+                image_data = fhandle.read()
+                with BytesIO(image_data) as bhandle:
+                    img = Image.open(bhandle)
+                    img = scale_and_crop_thumbnail(img, crop=crop)
+                    img.save(fpath_out)
+    except Exception as e:
+        raise ThumbnailGenerationError("Fail on zip {} {}".format(htmlfile, e))
+
+
+def create_image_from_pdf_page(fpath_in, fpath_out, page_number=0, crop=None):
+    """
+    Create an image from the pdf at fpath_in and write result to fpath_out.
+    """
+    try:
+        assert fpath_in.endswith('pdf'), "File must be in pdf format"
+        pages = convert_from_path(fpath_in, 500, first_page=page_number, last_page=page_number+1)
+        page = pages[0]
+        # resize
+        page = scale_and_crop_thumbnail(page, zoom=10, crop=crop)
+        page.save(fpath_out, 'PNG')
+    except Exception as e:
+        raise ThumbnailGenerationError("Fail on PDF {} {}".format(fpath_in, e))
+
+
+# TILED THUMBNAILS FOR TOPIC NODES (FOLDERS)
+################################################################################
+
+def create_tiled_image(source_images, fpath_out):
+    """
+    Create a 16:9 tiled image from list of image paths provided in source_images
+    and write result to fpath_out.
+    """
+    try:
+        sizes = {1:1, 4:2, 9:3, 16:4, 25:5, 36:6, 49:7}
+        assert len(source_images) in sizes.keys(), "Number of images must be a perfect square <= 49"
+        root = sizes[len(source_images)]
+
+        images = list(map(Image.open, source_images))
+        new_im = Image.new('RGBA', THUMBNAIL_SIZE)
+        offset = (int(float(THUMBNAIL_SIZE[0]) / float(root)),
+                  int(float(THUMBNAIL_SIZE[1]) / float(root)) )
+
+        index = 0
+        for y_index in range(root):
+            for x_index in range(root):
+                im = scale_and_crop_thumbnail(images[index], size=offset)
+                new_im.paste(im, (int(offset[0]*x_index), int(offset[1]*y_index)))
+                index = index + 1
+        new_im.save(fpath_out)
+    except Exception as e:
+        raise ThumbnailGenerationError("Failed due to {}".format(e))
+
+
+def convert_image(filename, dest_dir=None, size=None, format='PNG'):
+    """
+    Converts an image to a specified output format. The converted image will have the same
+    file basename as filename, but with the extension of the converted format.
+
+    :param filename: Filename of image to covert.
+    :param dest_dir: Destination directory for image, if None will save to same directory as filename.
+    :param size: Tuple of size of new image, if None, image is not resized.
+    :param format: File extension of format to convert to (e.g. PNG, JPG), Defaults to PNG.
+
+    :returns: Path to converted file.
+    """
+
+    assert os.path.exists(filename), "Image file not found: {}".format(os.path.abspath(filename))
+
+    if not dest_dir:
+        dest_dir = os.path.dirname(os.path.abspath(filename))
+
+    dest_filename_base = os.path.basename(filename)
+    base, ext = os.path.splitext(dest_filename_base)
+    new_filename = base + ".{}".format(format.lower())
+    dest_filename = os.path.join(dest_dir, new_filename)
+
+    img = Image.open(filename)
+
+    dest_img = img.convert("RGB")
+
+    # resive image to thumbnail dimensions
+    if size:
+        dest_img = dest_img.resize(size, Image.ANTIALIAS)
+    dest_img.save(dest_filename)
+
+    return dest_filename
+
+
+# EXCEPTIONS
+################################################################################
+
+class ThumbnailGenerationError(Exception):
+    """
+    Custom error returned when thumbnail extraction process fails.
+    """
+    pass
diff --git a/ricecooker/utils/proxy.py b/ricecooker/utils/proxy.py
new file mode 100644
index 00000000..5741bad1
--- /dev/null
+++ b/ricecooker/utils/proxy.py
@@ -0,0 +1,194 @@
+"""
+HTTP proxy utilities and retry logic.
+Set the ENV variable PROXY_LIST to a ;-separated list of {ip}:{port} values to
+specify the list of proxies to use. If PROXY_LIST a default list will be loaded
+from proxyscrape.com (note the default proxies can be very slow).
+"""
+import os
+import random
+import re
+import requests
+import time
+
+
+
+PROXY_LIST = []             # Current list of proxy servers to choose from
+
+RECENT_PROXIES = []         # Recently used proxies (to avoid using too often)
+RECENT_MAX = 3              # Rotatate between at least 3 proxy servers
+
+MAYBE_BROKEN_PROXIES = {}   # {proxy: error_list} to keep track of proxy errors
+ERROR_FORGET_TIME = 10      # Ignore proxy errors that are older than 10 mins
+ERROR_THRESHOLD = 3         # Add to broken list if encounter 3 errs in 10 mins
+
+BROKEN_PROXIES = []         # Known-bad proxies (we want to void choosing these)
+BROKEN_PROXIES_CACHE_FILENAME = 'broken_proxies.list'
+BROKEN_CACHE_EXPIRE_MINS = 2*24*60  # Ignore broken proxy cache older than 2 days
+
+
+
+# LOADERS
+################################################################################
+
+def load_env_proxies():
+    """
+    Load data from the ENV variable PROXY_LIST (a ;-sparated list of proxies).
+    """
+    proxy_list_env_var = os.getenv('PROXY_LIST', None)
+    proxy_list_env_var = proxy_list_env_var.strip(';').strip()
+    if proxy_list_env_var:
+        return [proxy.strip() for proxy in proxy_list_env_var.split(';')]
+    else:
+        return []
+
+def load_broken_proxies_cache():
+    """
+    Load data from 'broken_proxies.list' if the file not too old.
+    """
+    if not os.path.exists(BROKEN_PROXIES_CACHE_FILENAME):
+        return []
+    mtime = os.path.getmtime(BROKEN_PROXIES_CACHE_FILENAME)
+    if (time.time() - mtime) > 60*BROKEN_CACHE_EXPIRE_MINS:
+        os.remove(BROKEN_PROXIES_CACHE_FILENAME)
+        return []
+    broken_proxies = []
+    with open(BROKEN_PROXIES_CACHE_FILENAME, 'r') as bpl_file:
+        for line in bpl_file.readlines():
+            line = line.strip()
+            if line and not line.startswith('#'):
+                broken_proxy = line.split('#')[0].strip()
+                broken_proxies.append(broken_proxy)
+    return broken_proxies
+
+
+def get_proxyscape_proxies():
+    """
+    Loads a list of `{ip_address}:{port}` for public proxy servers.
+    """
+    PROXY_TIMOUT_LIMIT = "1000"
+    url = 'https://api.proxyscrape.com/?request=getproxies'
+    url += '&proxytype=http&country=all&ssl=yes&anonymity=all'
+    url += '&timeout=' + PROXY_TIMOUT_LIMIT
+    r = requests.get(url)
+    return r.text.split('\r\n')
+
+
+def get_sslproxies_proxies():
+    r = requests.get('https://sslproxies.org')
+    matches = re.findall(r"<td>\d+\.\d+\.\d+\.\d+</td><td>\d+</td>", r.text)
+    revised = [m.replace('<td>', '') for m in matches]
+    proxies = [s.replace('</td>', ':')[:-1] for s in revised]
+    return proxies
+
+
+def get_proxies(refresh=False):
+    """
+    Returns current list of proxies to sample from (contents of PROXY_LIST).
+    Use `refresh=True` to reload proxy list.
+    """
+    global PROXY_LIST
+
+    if len(PROXY_LIST) == 0 or refresh:
+        # This is either the first run or force-refresh of the list is requested
+        if os.getenv('PROXY_LIST', None):
+            proxy_list = load_env_proxies() # (re)load ;-spearated list from ENV
+        else:
+            proxy_list = get_proxyscape_proxies()
+        broken_proxy_list = load_broken_proxies_cache()
+        for proxy in proxy_list:
+            if proxy not in broken_proxy_list:
+                PROXY_LIST.append(proxy)
+
+    return PROXY_LIST
+
+
+
+# MAIN
+################################################################################
+
+def choose_proxy():
+    """
+    Main function called externally to get a random proxy from the PROXY_LIST.
+    """
+    global RECENT_PROXIES
+
+    proxies = get_proxies()
+
+    chosen = False
+    proxy = None
+    max_attempts = 30
+    retry_attempts = 10
+    attempt = 0
+    attempts_made = 0
+    while not chosen:
+        proxy = random.choice(proxies)
+        if not proxy in RECENT_PROXIES:
+            chosen = True
+            RECENT_PROXIES.append(proxy)
+            if len(RECENT_PROXIES) > RECENT_MAX:
+                RECENT_PROXIES.pop(0)
+        else:
+            attempt += 1
+            attempts_made += 1
+            if attempts_made > max_attempts:
+                break
+
+            # Some chefs can take hours or days, so our proxy list may be stale.
+            # Try refreshing the proxy list.
+            if attempt == retry_attempts:
+                attempt = 0
+                proxies = get_proxies(refresh=True)
+
+    return proxy
+
+
+
+# ERROR LOGIC
+################################################################################
+
+def record_error_for_proxy(proxy, exception=None):
+    """
+    Record a problem with the proxy server `proxy`, optionally passing in the
+    exact exception that occured in the calling code.
+    """
+    global MAYBE_BROKEN_PROXIES
+
+    error_dict = dict(
+        proxy=proxy,
+        timestamp=time.time(),
+        exception=exception,
+    )
+    if proxy in MAYBE_BROKEN_PROXIES:
+        proxy_errors = MAYBE_BROKEN_PROXIES[proxy]
+        recent_proxy_errors = []
+        for proxy_error in proxy_errors:
+            if (time.time() - proxy_error['timestamp']) < ERROR_FORGET_TIME*60:
+                recent_proxy_errors.append(proxy_error)
+        recent_proxy_errors.append(error_dict)
+        MAYBE_BROKEN_PROXIES[proxy] = recent_proxy_errors
+        if len(recent_proxy_errors) >= ERROR_THRESHOLD:
+            reason = str(exception).split('\n')[0] if exception else None
+            add_to_broken_proxy_list(proxy, reason=reason)
+    else:
+        MAYBE_BROKEN_PROXIES[proxy] = [error_dict]
+
+
+def add_to_broken_proxy_list(proxy, reason=''):
+    global BROKEN_PROXIES
+
+    if not proxy in BROKEN_PROXIES:
+        BROKEN_PROXIES.append(proxy)
+        with open(BROKEN_PROXIES_CACHE_FILENAME, 'a') as bpl_file:
+            line = proxy
+            if reason:
+                line += ' # ' + str(reason)
+            bpl_file.write(line + '\n')
+
+    if proxy in PROXY_LIST:
+        PROXY_LIST.remove(proxy)
+
+
+def reset_broken_proxy_list():
+    global BROKEN_PROXIES, MAYBE_BROKEN_PROXIES
+    BROKEN_PROXIES = []
+    MAYBE_BROKEN_PROXIES = {}
diff --git a/ricecooker/utils/subtitles.py b/ricecooker/utils/subtitles.py
new file mode 100644
index 00000000..1c03339c
--- /dev/null
+++ b/ricecooker/utils/subtitles.py
@@ -0,0 +1,264 @@
+import codecs
+from pycaption import CaptionSet, WebVTTWriter
+from pycaption import WebVTTReader, SRTReader, SAMIReader, SCCReader, DFXPReader
+from pycaption import CaptionReadError, CaptionReadNoCaptions
+from pycaption.base import DEFAULT_LANGUAGE_CODE
+from le_utils.constants import file_formats
+
+
+LANGUAGE_CODE_UNKNOWN = DEFAULT_LANGUAGE_CODE
+
+
+class InvalidSubtitleFormatError(TypeError):
+    """
+    Custom error indicating a format that is invalid
+    """
+    pass
+
+
+class InvalidSubtitleLanguageError(ValueError):
+    """
+    Custom error indicating that the provided language isn't present in a captions file
+    """
+    pass
+
+
+class SubtitleReader:
+    """
+    A wrapper class for the pycaption readers since the interface differs between all. This will
+    call read with `LANGUAGE_CODE_UNKNOWN` if `requires_language` is `True`
+    """
+    def __init__(self, reader, requires_language=False):
+        """
+        :param reader: A pycaption reader
+        :type reader: WebVTTReader, SRTReader, SAMIReader, SCCReader, DFXPReader
+        :param requires_language: A boolean specifying whether the reader requires a language
+        :type requires_language: bool
+        """
+        self.reader = reader
+        self.requires_language = requires_language
+
+    def read(self, caption_str):
+        """
+        Handles detecting and reading the captions
+
+        :param caption_str: A string with the captions contents
+        :type caption_str: str
+        :return: The captions from the file in a `CaptionSet` or `None` if unsupported
+        :rtype: CaptionSet, None
+        """
+        # attempt detection first, being accepting of failure
+        try:
+            if not self.reader.detect(caption_str):
+                return None
+        except UnicodeDecodeError:
+            return None
+
+        # after detection, if read fails, be aggressive and throw an error
+        try:
+            if self.requires_language:
+                return self.reader.read(caption_str, lang=LANGUAGE_CODE_UNKNOWN)
+
+            return self.reader.read(caption_str)
+        except CaptionReadNoCaptions:
+            raise InvalidSubtitleFormatError('Caption file has no captions')
+        except (CaptionReadError, UnicodeDecodeError) as e:
+            raise InvalidSubtitleFormatError('Caption file is invalid: {}'.format(e))
+        # allow other errors to be passed through
+
+
+class SubtitleConverter:
+    """
+    This class converts subtitle files to the preferred VTT format
+    """
+    def __init__(self, readers, caption_str):
+        """
+        :param readers: An array of `SubtitleReader` instances
+        :param caption_str: A string with the captions content
+        """
+        self.readers = readers
+        self.caption_str = caption_str
+        self.writer = WebVTTWriter()
+        # set "video size" to 100 since other types may have layout, 100 should work to generate %
+        self.writer.video_width = 100
+        self.writer.video_height = self.writer.video_width * 6 / 19
+        self.caption_set = None
+
+    def get_caption_set(self):
+        """
+        Detects and reads the `caption_str` into the cached `caption_set` property and returns it.
+
+        :return: CaptionSet
+        """
+        if self.caption_set:
+            return self.caption_set
+
+        for reader in self.readers:
+            self.caption_set = reader.read(self.caption_str)
+            if self.caption_set is not None:
+                break
+        else:
+            self.caption_set = None
+            raise InvalidSubtitleFormatError('Subtitle file is unsupported or unreadable')
+
+        if self.caption_set.is_empty():
+            raise InvalidSubtitleLanguageError('Captions set is invalid')
+        return self.caption_set
+
+    def get_language_codes(self):
+        """
+        This gets the language codes as defined by the caption string. Some caption formats do not
+        specify languages, which in that case a special code (constant `LANGUAGE_CODE_UNKNOWN`)
+        will be present.
+
+        :return: An array of language codes as defined in the subtitle file.
+        """
+        return self.get_caption_set().get_languages()
+
+    def has_language(self, lang_code):
+        """
+        Determines if current caption set to be converted is/has an unknown language. This would
+        happen with SRT or other files where language is not specified
+
+        :param: lang_code: A string of the language code to check
+        :return: bool
+        """
+        return lang_code in self.get_language_codes()
+
+    def replace_unknown_language(self, lang_code):
+        """
+        This essentially sets the "unknown" language in the caption set, by replacing the key
+        with this new language code
+
+        :param lang_code: A string with the language code to replace the unknown language with
+        """
+        caption_set = self.get_caption_set()
+
+        captions = {}
+        for lang in caption_set.get_languages():
+            set_lang = lang_code if lang == LANGUAGE_CODE_UNKNOWN else lang
+            captions[set_lang] = caption_set.get_captions(lang)
+
+        # Replace caption_set with new version, having replaced unknown language
+        self.caption_set = CaptionSet(
+            captions, styles=dict(caption_set.get_styles()), layout_info=caption_set.layout_info)
+
+    def write(self, out_filename, lang_code):
+        """
+        Convenience method to write captions as file. Captions contents must be unicode for
+        conversion.
+
+        :param out_filename: A string path to put the converted captions contents
+        :param lang_code: A string of the language code to write
+        """
+        with codecs.open(out_filename, 'w', encoding='utf-8') as converted_file:
+            converted_file.write(self.convert(lang_code))
+
+    def convert(self, lang_code):
+        """
+        Converts the caption set to the VTT format
+
+        :param lang_code: A string with one of the languages to output the captions for
+        :type: lang_code: str
+        :return: A string with the converted caption contents
+        :rtype: str
+        """
+        caption_set = self.get_caption_set()
+        captions = caption_set.get_captions(lang_code)
+
+        if not captions:
+            raise InvalidSubtitleLanguageError(
+                "Language '{}' is not present in caption set".format(lang_code))
+
+        styles = caption_set.get_styles()
+        layout_info = caption_set.get_layout_info(lang_code)
+        lang_caption_set = CaptionSet(
+            {lang_code: captions}, styles=dict(styles), layout_info=layout_info)
+        return self.writer.write(lang_caption_set)
+
+
+#####################
+# FACTORY FUNCTIONS #
+#####################
+
+def build_dfxp_reader():
+    return SubtitleReader(DFXPReader())
+
+
+def build_sami_reader():
+    return SubtitleReader(SAMIReader())
+
+
+def build_scc_reader():
+    return SubtitleReader(SCCReader(), requires_language=True)
+
+
+def build_srt_reader():
+    return SubtitleReader(SRTReader(), requires_language=True)
+
+
+def build_vtt_reader():
+    return SubtitleReader(WebVTTReader(), requires_language=True)
+
+
+BUILD_READER_MAP = {
+    file_formats.VTT: build_vtt_reader,
+    file_formats.SRT: build_srt_reader,
+    file_formats.SAMI: build_sami_reader,
+    file_formats.SCC: build_scc_reader,
+    file_formats.TTML: build_dfxp_reader,
+    file_formats.DFXP: build_dfxp_reader,
+}
+
+
+def build_subtitle_reader(reader_format):
+    if reader_format not in BUILD_READER_MAP:
+        raise InvalidSubtitleFormatError('Unsupported')
+    return BUILD_READER_MAP[reader_format]()
+
+
+def build_subtitle_readers():
+    readers = []
+    for reader_format, build in BUILD_READER_MAP.items():
+        readers.append(build())
+    return readers
+
+
+def build_subtitle_converter(caption_str, in_format=None):
+    """
+    Builds a subtitle converter used to convert subtitle files to VTT format
+
+    :param caption_str: A string with the captions contents
+    :type: captions_str: str
+    :param in_format: A string with expected format of the file to be converted
+    :type: in_format: str
+    :return: A SubtitleConverter
+    :rtype: SubtitleConverter
+    """
+    readers = []
+    if in_format is not None:
+        readers.append(build_subtitle_reader(in_format))
+    else:
+        readers = build_subtitle_readers()
+
+    return SubtitleConverter(readers, caption_str)
+
+
+def build_subtitle_converter_from_file(captions_filename, in_format=None):
+    """
+    Reads `captions_filename` as the file to be converted, and returns a `SubtitleConverter`
+    instance that can be used to do the conversion.
+
+    :param captions_filename: A string path to the captions file to parse
+    :type: captions_filename: str
+    :param in_format: A string with expected format of `captions_filename`, otherwise detected
+    :type: in_format: str
+    :return: A SubtitleConverter
+    :rtype: SubtitleConverter
+    """
+    with codecs.open(captions_filename, encoding='utf-8') as captions_file:
+        captions_str = captions_file.read()
+
+    return build_subtitle_converter(captions_str, in_format)
+
+
diff --git a/ricecooker/utils/thumbscropping.py b/ricecooker/utils/thumbscropping.py
new file mode 100644
index 00000000..7ce46462
--- /dev/null
+++ b/ricecooker/utils/thumbscropping.py
@@ -0,0 +1,224 @@
+import math
+import re
+from PIL import Image
+import sys
+import types
+
+# Useful for very coarse version differentiation.
+PY2 = sys.version_info[0] == 2
+PY3 = sys.version_info[0] == 3
+
+if PY3:
+    string_types = str,
+    integer_types = int,
+    class_types = type,
+    text_type = str
+    binary_type = bytes
+else:
+    string_types = basestring,
+    integer_types = (int, long)
+    class_types = (type, types.ClassType)
+    text_type = unicode
+    binary_type = str
+
+# SmileyChris/easy-thumbnails is licensed under the BSD 3-Clause "New" or "Revised" License
+# Copyright (c) 2009, Chris Beaven
+# All rights reserved.
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#     * Redistributions of source code must retain the above copyright notice,
+#       this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright notice,
+#       this list of conditions and the following disclaimer in the documentation
+#       and/or other materials provided with the distribution.
+#     * Neither the name easy-thumbnails nor the names of its contributors may be
+#       used to endorse or promote products derived from this software without
+#       specific prior written permission.
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+def image_entropy(im):
+    """
+    Calculate the entropy of an image. Used for "smart cropping".
+    """
+    if not isinstance(im, Image.Image):
+        # Can only deal with PIL images. Fall back to a constant entropy.
+        return 0
+    hist = im.histogram()
+    hist_size = float(sum(hist))
+    hist = [h / hist_size for h in hist]
+    return -sum([p * math.log(p, 2) for p in hist if p != 0])
+
+
+
+def _compare_entropy(start_slice, end_slice, slice, difference):
+    """
+    Calculate the entropy of two slices (from the start and end of an axis),
+    returning a tuple containing the amount that should be added to the start
+    and removed from the end of the axis.
+    """
+    start_entropy = image_entropy(start_slice)
+    end_entropy = image_entropy(end_slice)
+    if end_entropy and abs(start_entropy / end_entropy - 1) < 0.01:
+        # Less than 1% difference, remove from both sides.
+        if difference >= slice * 2:
+            return slice, slice
+        half_slice = slice // 2
+        return half_slice, slice - half_slice
+    if start_entropy > end_entropy:
+        return 0, slice
+    else:
+        return slice, 0
+
+
+
+def scale_and_crop(im, size, crop=False, upscale=False, zoom=None, target=None, **kwargs):
+    """
+    Handle scaling and cropping the source image.
+    Images can be scaled / cropped against a single dimension by using zero
+    as the placeholder in the size. For example, ``size=(100, 0)`` will cause
+    the image to be resized to 100 pixels wide, keeping the aspect ratio of
+    the source image.
+    crop
+        Crop the source image height or width to exactly match the requested
+        thumbnail size (the default is to proportionally resize the source
+        image to fit within the requested thumbnail size).
+        By default, the image is centered before being cropped. To crop from
+        the edges, pass a comma separated string containing the ``x`` and ``y``
+        percentage offsets (negative values go from the right/bottom). Some
+        examples follow:
+        * ``crop="0,0"`` will crop from the left and top edges.
+        * ``crop="-10,-0"`` will crop from the right edge (with a 10% offset)
+          and the bottom edge.
+        * ``crop=",0"`` will keep the default behavior for the x axis
+          (horizontally centering the image) and crop from the top edge.
+        The image can also be "smart cropped" by using ``crop="smart"``. The
+        image is incrementally cropped down to the requested size by removing
+        slices from edges with the least entropy.
+        Finally, you can use ``crop="scale"`` to simply scale the image so that
+        at least one dimension fits within the size dimensions given (you may
+        want to use the upscale option too).
+    upscale
+        Allow upscaling of the source image during scaling.
+    zoom
+        A percentage to zoom in on the scaled image. For example, a zoom of
+        ``40`` will clip 20% off each side of the source image before
+        thumbnailing.
+    target
+        Set the focal point as a percentage for the image if it needs to be
+        cropped (defaults to ``(50, 50)``).
+        For example, ``target="10,20"`` will set the focal point as 10% and 20%
+        from the left and top of the image, respectively. If the image needs to
+        be cropped, it will trim off the right and bottom edges until the focal
+        point is centered.
+        Can either be set as a two-item tuple such as ``(20, 30)`` or a comma
+        separated string such as ``"20,10"``.
+        A null value such as ``(20, None)`` or ``",60"`` will default to 50%.
+    """
+    source_x, source_y = [float(v) for v in im.size]
+    target_x, target_y = [int(v) for v in size]
+
+    if crop or not target_x or not target_y:
+        scale = max(target_x / source_x, target_y / source_y)
+    else:
+        scale = min(target_x / source_x, target_y / source_y)
+
+    # Handle one-dimensional targets.
+    if not target_x:
+        target_x = round(source_x * scale)
+    elif not target_y:
+        target_y = round(source_y * scale)
+
+    if zoom:
+        if not crop:
+            target_x = round(source_x * scale)
+            target_y = round(source_y * scale)
+            crop = True
+        scale *= (100 + int(zoom)) / 100.0
+
+    if scale < 1.0 or (scale > 1.0 and upscale):
+        # Resize the image to the target size boundary. Round the scaled
+        # boundary sizes to avoid floating point errors.
+        im = im.resize((int(round(source_x * scale)),
+                        int(round(source_y * scale))),
+                       resample=Image.ANTIALIAS)
+
+    if crop:
+        # Use integer values now.
+        source_x, source_y = im.size
+        # Difference between new image size and requested size.
+        diff_x = int(source_x - min(source_x, target_x))
+        diff_y = int(source_y - min(source_y, target_y))
+        if crop != 'scale' and (diff_x or diff_y):
+            if isinstance(target, string_types):
+                target = re.match(r'(\d+)?,(\d+)?$', target)
+                if target:
+                    target = target.groups()
+            if target:
+                focal_point = [int(n) if (n or n == 0) else 50 for n in target]
+            else:
+                focal_point = 50, 50
+            # Crop around the focal point
+            halftarget_x, halftarget_y = int(target_x / 2), int(target_y / 2)
+            focal_point_x = int(source_x * focal_point[0] / 100)
+            focal_point_y = int(source_y * focal_point[1] / 100)
+            box = [
+                max(0, min(source_x - target_x, focal_point_x - halftarget_x)),
+                max(0, min(source_y - target_y, focal_point_y - halftarget_y)),
+            ]
+            box.append(int(min(source_x, box[0] + target_x)))
+            box.append(int(min(source_y, box[1] + target_y)))
+            # See if an edge cropping argument was provided.
+            edge_crop = (isinstance(crop, string_types) and
+                         re.match(r'(?:(-?)(\d+))?,(?:(-?)(\d+))?$', crop))
+            if edge_crop and filter(None, edge_crop.groups()):
+                x_right, x_crop, y_bottom, y_crop = edge_crop.groups()
+                if x_crop:
+                    offset = min(int(target_x) * int(x_crop) // 100, diff_x)
+                    if x_right:
+                        box[0] = diff_x - offset
+                        box[2] = source_x - offset
+                    else:
+                        box[0] = offset
+                        box[2] = source_x - (diff_x - offset)
+                if y_crop:
+                    offset = min(int(target_y) * int(y_crop) // 100, diff_y)
+                    if y_bottom:
+                        box[1] = diff_y - offset
+                        box[3] = source_y - offset
+                    else:
+                        box[1] = offset
+                        box[3] = source_y - (diff_y - offset)
+            # See if the image should be "smart cropped".
+            elif crop == 'smart':
+                left = top = 0
+                right, bottom = source_x, source_y
+                while diff_x:
+                    slice = min(diff_x, max(diff_x // 5, 10))
+                    start = im.crop((left, 0, left + slice, source_y))
+                    end = im.crop((right - slice, 0, right, source_y))
+                    add, remove = _compare_entropy(start, end, slice, diff_x)
+                    left += add
+                    right -= remove
+                    diff_x = diff_x - add - remove
+                while diff_y:
+                    slice = min(diff_y, max(diff_y // 5, 10))
+                    start = im.crop((0, top, source_x, top + slice))
+                    end = im.crop((0, bottom - slice, source_x, bottom))
+                    add, remove = _compare_entropy(start, end, slice, diff_y)
+                    top += add
+                    bottom -= remove
+                    diff_y = diff_y - add - remove
+                box = (left, top, right, bottom)
+            # Finally, crop the image!
+            im = im.crop(box)
+    return im
diff --git a/ricecooker/utils/utils.py b/ricecooker/utils/utils.py
new file mode 100644
index 00000000..d8c17c16
--- /dev/null
+++ b/ricecooker/utils/utils.py
@@ -0,0 +1,20 @@
+import os
+
+
+def make_dir_if_needed(path):
+    """
+    Check if the dir exists, and if not, create it. If the directory exists, just return it
+    rather than throwing an error.
+
+    :param path: A string representing a directory on disk.
+    :return: A path to the directory that is guaranteed to exist.
+    """
+
+    if not os.path.exists(path):
+        os.makedirs(path)
+    return path
+
+
+class VideoURLFormatError(Exception):
+    def __init__(self, url, expected_format):
+        self.message = "The video at {} does not appear to be a proper {} video URL.".format(url, expected_format)
\ No newline at end of file
diff --git a/ricecooker/utils/videos.py b/ricecooker/utils/videos.py
new file mode 100644
index 00000000..0c43c6fc
--- /dev/null
+++ b/ricecooker/utils/videos.py
@@ -0,0 +1,94 @@
+import re
+import subprocess
+import logging
+
+from le_utils.constants import format_presets
+
+from .images import ThumbnailGenerationError
+
+LOGGER = logging.getLogger("VideoResource")
+LOGGER.setLevel(logging.DEBUG)
+
+def guess_video_preset_by_resolution(videopath):
+    """
+    Run `ffprobe` to find resolution classify as high resolution (video height >= 720),
+    or low resolution (video height < 720).
+    Return appropriate video format preset: VIDEO_HIGH_RES or VIDEO_LOW_RES.
+    """
+    try:
+        LOGGER.debug("Entering 'guess_video_preset_by_resolution' method")
+        result = subprocess.check_output(['ffprobe', '-v', 'error', '-print_format', 'json', '-show_entries',
+                                          'stream=width,height', '-of', 'default=noprint_wrappers=1', str(videopath)])
+        LOGGER.debug("ffprobe stream result = {}".format(result))
+        pattern = re.compile('width=([0-9]*)[^height]+height=([0-9]*)')
+        match = pattern.search(str(result))
+        if match is None:
+            return format_presets.VIDEO_LOW_RES
+        width, height = int(match.group(1)), int(match.group(2))
+        if height >= 720:
+            LOGGER.info('Video preset from {} = high resolution'.format(videopath))
+            return format_presets.VIDEO_HIGH_RES
+        else:
+            LOGGER.info('Video preset from {} = low resolution'.format(videopath))
+            return format_presets.VIDEO_LOW_RES
+    except Exception as e:
+        LOGGER.warning(e)
+        return format_presets.VIDEO_LOW_RES
+
+
+def extract_thumbnail_from_video(fpath_in, fpath_out, overwrite=False):
+    """
+    Extract a thumbnail from the video given through the `fobj_in` file object.
+    The thumbnail image will be written in the file object given in `fobj_out`.
+    """
+    try:
+        result = subprocess.check_output(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of',
+                                          'default=noprint_wrappers=1:nokey=1', "-loglevel", "panic", str(fpath_in)])
+
+        midpoint = float(re.search("\d+\.\d+", str(result)).group()) / 2
+        # scale parameters are from https://trac.ffmpeg.org/wiki/Scaling
+        scale = "scale=400:225:force_original_aspect_ratio=decrease,pad=400:225:(ow-iw)/2:(oh-ih)/2"
+        command = ['ffmpeg',"-y" if overwrite else "-n", '-i', str(fpath_in), "-vf", scale, "-vcodec", "png", "-nostats",
+                  '-ss', str(midpoint), '-vframes', '1', '-q:v', '2', "-loglevel", "panic", str(fpath_out)]
+        subprocess.check_output(command, stderr=subprocess.STDOUT)
+    except subprocess.CalledProcessError as e:
+        raise ThumbnailGenerationError("{}: {}".format(e, e.output))
+
+
+class VideoCompressionError(Exception):
+    """
+    Custom error returned when `ffmpeg` compression exits with a non-zero status.
+    """
+    pass
+
+
+def compress_video(source_file_path, target_file, overwrite=False, **kwargs):
+    """
+    Compress and scale video at `source_file_path` using setting provided in `kwargs`:
+      - max_height (int): set a limit for maximum vertical resolution (default: 480)
+      - max_width (int): set a limit for maximum horizontal resolution for video
+      - crf (int): set compression constant rate factor (default 32 = compress a lot)
+    Save compressed output video to `target_file`.
+    """
+    # scaling
+    # The output width and height for ffmpeg scale param must be divisible by 2
+    # using value -2 to get robust behaviour: maintains the aspect ratio and also
+    # ensure the calculated dimension is divisible by 2
+    if 'max_width' in kwargs:
+        scale = "'w=trunc(min(iw,{max_width})/2)*2:h=-2'".format(max_width=kwargs['max_width'])
+    elif 'max_height' in kwargs:
+        scale = "'w=-2:h=trunc(min(ih,{max_height})/2)*2'".format(max_height=kwargs['max_height'])
+    else:
+        scale = "'w=-2:h=trunc(min(ih,480)/2)*2'"  # default to max-height 480px
+
+    # set constant rate factor, see https://trac.ffmpeg.org/wiki/Encode/H.264#crf
+    crf = kwargs['crf'] if 'crf' in kwargs else 32
+
+    # run command
+    command = ["ffmpeg", "-y" if overwrite else "-n", "-i", source_file_path, "-profile:v", "baseline",
+               "-level", "3.0", "-b:a", "32k", "-ac", "1", "-vf", "scale={}".format(scale),
+               "-crf", str(crf), "-preset", "slow", "-v", "error", "-strict", "-2", "-stats", target_file]
+    try:
+        subprocess.check_output(command, stderr=subprocess.STDOUT)
+    except subprocess.CalledProcessError as e:
+        raise VideoCompressionError("{}: {}".format(e, e.output))
diff --git a/ricecooker/utils/web.py b/ricecooker/utils/web.py
new file mode 100644
index 00000000..e72ee033
--- /dev/null
+++ b/ricecooker/utils/web.py
@@ -0,0 +1,91 @@
+"""
+This module contains tools for parsing and handling HTML and other web content.
+Note that we could not use html for the module name as recent versions of Python
+include their own html module.
+"""
+
+import os
+
+from bs4 import BeautifulSoup
+
+
+class HTMLParser:
+    """
+    HTMLParser contains a set of functions for parsing, scraping, and updating an HTML page.
+    """
+    def __init__(self, filename=None, html=None):
+        self.filename = filename
+        self.html = html
+        self.link_tags = {
+            'a': 'href',
+            'audio': 'src',
+            'img': 'src',
+            'link': 'href',
+            'script': 'src'
+        }
+
+    def get_links(self):
+        """
+        Retrieves all links contained within the page.
+
+        :return: A list of local and remote URLs in the page.
+        """
+        basename = None
+        if self.html is None:
+            basename = os.path.basename(self.filename)
+            self.html = open(self.filename).read()
+        soup = BeautifulSoup(self.html, 'html.parser')
+
+        extracted_links = []
+        for tag_name in self.link_tags:
+            tags = soup.find_all(tag_name)
+            for tag in tags:
+                link = tag.get(self.link_tags[tag_name])
+                # don't include links to ourselves or # links
+                # TODO: Should this part be moved to get_local_files instead?
+                if link and (basename and not link.startswith(basename)) and not link.strip().startswith("#"):
+                    if '?' in link:
+                        link, query = link.split('?')
+                    if '#' in link:
+                        link, marker = link.split('#')
+                    extracted_links.append(link)
+
+        return extracted_links
+
+    def get_local_files(self):
+        """
+        Returns a list of files that are contained in the same directory as the HTML page or in its subdirectories.
+
+        :return: A list of local files
+        """
+        links = self.get_links()
+        local_links = []
+        for link in links:
+            # NOTE: This technically fails to handle file:// URLs, but we're highly unlikely to see
+            # file:// URLs in any distributed package, so this is simpler than parsing out the protocol.
+            if not '://' in link:
+                local_links.append(link)
+
+        return local_links
+
+    def replace_links(self, links_to_replace):
+        """
+        Updates page links using the passed in replacement dictionary.
+
+        :param links_to_replace: A dictionary of OriginalURL -> ReplacementURL key value pairs.
+        :return: An HTML string of the page with all links replaced.
+        """
+        if self.html is None:
+            basename = os.path.basename(self.filename)
+            self.html = open(self.filename).read()
+        soup = BeautifulSoup(self.html, 'html.parser')
+
+        extracted_links = []
+        for tag_name in self.link_tags:
+            tags = soup.find_all(tag_name)
+            for tag in tags:
+                link = tag.get(self.link_tags[tag_name])
+                if link in links_to_replace:
+                    tag[self.link_tags[tag_name]] = links_to_replace[link]
+
+        return soup.prettify()
\ No newline at end of file
diff --git a/ricecooker/utils/youtube.py b/ricecooker/utils/youtube.py
index 0e0196f6..e933fd09 100644
--- a/ricecooker/utils/youtube.py
+++ b/ricecooker/utils/youtube.py
@@ -1,12 +1,367 @@
 from enum import Enum
+import copy
 import json
+import logging
 import os
+import time
 import re
 import youtube_dl
 
-from pressurecooker.youtube import YouTubeResource
+from datetime import datetime
+from le_utils.constants import languages
 from ricecooker.config import LOGGER
 
+from . import proxy
+from . import utils
+
+
+LOGGER = logging.getLogger("YouTubeResource")
+LOGGER.setLevel(logging.DEBUG)
+
+
+NON_NETWORK_ERRORS = [
+    youtube_dl.utils.ExtractorError,        # private and unlisted videos
+    youtube_dl.utils.PostProcessingError,   # custom postprocessors failures
+]
+
+
+def get_youtube_info(youtube_url):
+    """
+    Convenience function for retrieving YouTube resource information. Wraps YouTubeResource.get_resource_info.
+
+    :param youtube_url: URL of YouTube resource to get information on.
+
+    :return: A dictionary object containing information about the YouTube resource.
+    """
+    resource = YouTubeResource(youtube_url)
+    return resource.get_resource_info()
+
+
+class YouTubeResource(object):
+    """
+    This class encapsulates functionality for information retrieval and download
+    of YouTube resources. Resources may include videos, playlists and channels.
+    """
+    # If extract_info request takes longer than this we treat it as broken proxy
+    EXTRACT_TIME_SLOW_LIMIT = 20  # in seconds
+
+    def __init__(self, url, useproxy=True, high_resolution=False, options=None):
+        """
+        Initializes the YouTube resource, and calls the get_resource_info method to retrieve resource information.
+
+        :param url: URL of a YouTube resource. URL may point to a video, playlist or channel.
+        """
+        if not 'youtube.com' in url and not 'youtu.be' in url:
+            raise utils.VideoURLFormatError(url, 'YouTube')
+        self.url = url
+        self.subtitles = {}
+        self.num_retries = 10
+        self.sleep_seconds = 0.5
+        self.preferred_formats = {
+            'video': 'mp4',
+            'audio': 'm4a'
+        }
+        self.useproxy = useproxy
+        self.high_resolution = high_resolution
+        self.options = options
+        self.client = None  # this will become a YoutubeDL instance on first use
+        self.info = None    # save detailed info_dict returned from extract_info
+
+
+    def get_resource_info(self, options=None):
+        """
+        This method checks the YouTube URL, then returns a dictionary object with info about the video(s) in it.
+
+        :return: A ricecooker-like dict of info about the channel, playlist or video.
+        """
+        extract_info_options = dict(
+            verbose = True,  # TODO(ivan) change this to quiet = True eventually
+            no_warnings = True,
+            no_color = True,
+            # By default, YouTubeDL will pick what it determines to be the best formats, but for consistency's sake
+            # we want to always get preferred formats (default of mp4 and m4a) when possible.
+            format = "bestvideo[height<={maxheight}][ext={vext}]+bestaudio[ext={aext}]/best[height<={maxheight}][ext={vext}]".format(
+                maxheight=720 if self.high_resolution else 480,
+                vext=self.preferred_formats['video'],
+                aext=self.preferred_formats['audio']
+            ),
+        )
+
+        for i in range(self.num_retries):
+            if self.useproxy:
+                dl_proxy = proxy.choose_proxy()
+                extract_info_options['proxy'] = dl_proxy
+            if self.options:
+                extract_info_options.update(self.options)  # init-time options
+            if options:
+                extract_info_options.update(options)       # additional options
+
+            try:
+                LOGGER.debug("YoutubeDL options = {}".format(extract_info_options))
+                self.client = youtube_dl.YoutubeDL(extract_info_options)
+                self.client.add_default_info_extractors()
+
+                LOGGER.debug("Calling extract_info for URL {}".format(self.url))
+                start_time = datetime.now()
+                self.info = self.client.extract_info(self.url, download=False, process=True)
+                end_time = datetime.now()
+
+                # Mark slow proxies as broken
+                extract_time = (end_time - start_time).total_seconds()
+                LOGGER.debug('extract_time = ' + str(extract_time))
+                if self.useproxy and extract_time > self.EXTRACT_TIME_SLOW_LIMIT:
+                    if 'entries' in self.info:
+                        pass  # it's OK for extract_info to be slow for playlists
+                    else:
+                        proxy.record_error_for_proxy(dl_proxy, exception='extract_info took ' + extract_time + ' seconds')
+                        LOGGER.info("Found slow proxy {}".format(dl_proxy))
+
+                # Format info JSON into ricecooker-like keys
+                edited_results = self._format_for_ricecooker(self.info)
+                return edited_results
+
+            except Exception as e:
+                network_related_error = True
+                if isinstance(e, youtube_dl.utils.DownloadError):
+                    (eclass, evalue, etraceback) = e.exc_info
+                    if eclass in NON_NETWORK_ERRORS:
+                        network_related_error = False
+                if self.useproxy and network_related_error:
+                    # Add the current proxy to the BROKEN_PROXIES list
+                    proxy.record_error_for_proxy(dl_proxy, exception=e)
+                LOGGER.warning(e)
+                if i < self.num_retries - 1:
+                    LOGGER.warning("Info extraction failed, retrying...")
+                    time.sleep(self.sleep_seconds)
+
+
+    def get_dir_name_from_url(self, url=None):
+        """
+        Takes a URL and returns a directory name to store files in.
+
+        :param url: URL of a YouTube resource, if None, defaults to the url passed to the YouTubeResource object
+        :return: (String) directory name
+        """
+        if url is None:
+            url = self.url
+        name = url.split("/")[-1]
+        name = name.split("?")[0]
+        return " ".join(name.split("_")).title()
+
+
+    def download(self, base_path=None, useproxy=False, options=None):
+        """
+        Download the YouTube resource(s) specified in `self.info`. If `self.info`
+        is None, it will be populated by calling `self.get_resource_info` which
+        in turn uses `self.url`. Returns None if download fails.
+        """
+        if base_path:
+            download_dir = os.path.join(base_path, self.get_dir_name_from_url())
+            utils.make_dir_if_needed(download_dir)
+        else:
+            download_dir = '.'
+
+        if self.client is None or self.info is None:
+            # download should always be called after self.info is available
+            self.get_resource_info()
+
+        # Set reasonable default download options...
+        self.client.params['outtmpl'] = '{}/%(id)s.%(ext)s'.format(download_dir)
+        self.client.params['writethumbnail'] = True  # TODO(ivan): revisit this
+        self.client.params['continuedl'] = False  # clean start to avoid errors
+        self.client.params['noprogress'] = True   # progressbar doesn't log well
+        if options:
+            # ...but override them based on user choices when specified
+            self.client.params.update(options)
+        LOGGER.debug("Using download options = {}".format(self.client.params))
+
+        LOGGER.info("Downloading {} to dir {}".format(self.url, download_dir))
+        for i in range(self.num_retries):
+
+            # Proxy configuration for download (default = no proxy)
+            if useproxy:
+                # If useproxy ovverride specified, choose a new proxy server:
+                dl_proxy = proxy.choose_proxy()
+                self.client.params['proxy'] = dl_proxy
+                self.client._setup_opener()  # this will re-initialize downloader
+            elif not useproxy and 'proxy' in self.client.params and self.client.params['proxy']:
+                # Disable proxy if it was used for the get_resource_info call
+                self.client.params['proxy'] = None
+                self.client._setup_opener()  # this will re-initialize downloader
+
+            try:
+                self.info = self.client.process_ie_result(self.info, download=True)
+                LOGGER.debug('Finished process_ie_result successfully')
+                break
+            except Exception as e:
+                network_related_error = True
+                if isinstance(e, youtube_dl.utils.DownloadError):
+                    (eclass, evalue, etraceback) = e.exc_info
+                    if eclass in NON_NETWORK_ERRORS:
+                        network_related_error = False
+                if useproxy and network_related_error:
+                    # Add the current proxy to the BROKEN_PROXIES list
+                    proxy.record_error_for_proxy(dl_proxy, exception=e)
+                if self.info:
+                    # cleanup partially downloaded file to get a clean start
+                    download_filename = self.client.prepare_filename(self.info)
+                    if os.path.exists(download_filename):
+                        os.remove(download_filename)
+                LOGGER.warning(e)
+                if i < self.num_retries - 1:
+                    LOGGER.warning("Download {} failed, retrying...".format(i+1))
+                    time.sleep(self.sleep_seconds)
+
+        # Post-process results
+        # TODO(ivan): handle post processing filename when custom `outtmpl` specified in options
+        if self.info:
+            edited_results = self._format_for_ricecooker(self.info)
+            if 'children' in edited_results:
+                for child in edited_results['children']:
+                    vfilename = "{}.{}".format(child["id"], child['ext'])
+                    child['filename'] = os.path.join(download_dir, vfilename)
+            else:
+                vfilename = "{}.{}".format(edited_results["id"], edited_results['ext'])
+                edited_results['filename'] = os.path.join(download_dir, vfilename)
+            return edited_results
+        else:
+            return None
+
+
+    def get_resource_subtitles(self, options=None):
+        """
+        Retrieves the subtitles for the video(s) represented by this resource.
+        Subtitle information will be contained in the 'subtitles' key of the
+        dictionary object returned.
+
+        :return: A dictionary object that contains information about video subtitles
+        """
+        options_for_subtitles = dict(
+            writesubtitles = True,      # extract subtitles info
+            allsubtitles = True,        # get all available languages
+            writeautomaticsub = False,  # do not include auto-generated subs
+        )
+        if options:
+            options_for_subtitles.update(options)
+
+        info = self.get_resource_info(options=options_for_subtitles)
+        return info
+
+
+    def _format_for_ricecooker(self, results):
+        """
+        Internal method for converting YouTube resource info into the format expected by ricecooker.
+
+        :param results: YouTube resource dictionary object to be converted to ricecooker format.
+
+        :return: A dictionary object in the format expected by ricecooker.
+        """
+        leaf = {}
+
+        # dict mapping of field name and default value when not found.
+        extracted_fields = {
+            'id': '',
+            'title': '',
+            'description': '',
+            'ext': 'mp4',
+            'thumbnail': '',
+            'webpage_url': '',
+            'tags': [],
+            'subtitles': {},
+            'requested_subtitles': '',
+            'artist': '',
+            'license': '',
+            '_type': 'video'
+        }
+
+        for field_name in extracted_fields:
+            info_name = field_name
+            if info_name == '_type':
+                info_name = 'kind'
+            elif info_name == 'webpage_url':
+                info_name = 'source_url'
+            if field_name in results:
+                leaf[info_name] = results[field_name]
+            else:
+                leaf[info_name] = extracted_fields[field_name]
+
+        if 'entries' in results:
+            leaf['children'] = []
+            for entry in results['entries']:
+                if entry is not None:
+                    leaf['children'].append(self._format_for_ricecooker(entry))
+                else:
+                    LOGGER.info("Skipping None entry bcs failed extract info")
+
+        return leaf
+
+
+    def check_for_content_issues(self, filter=False):
+        """
+        Checks the YouTube resource and looks for any issues that may prevent download or distribution of the material,
+        or would otherwise imply that the resource is not suitable for use in Kolibri.
+
+        :param filter: If True, remove videos with issues from the returned resource info. Defaults to False.
+
+        :return: A tuple containing a list of videos with waranings, and the resource info as a dictionary object.
+        """
+        resource_info = self.get_resource_info()
+        output_video_info = copy.copy(resource_info)
+        videos_with_warnings = []
+        if filter:
+            output_video_info['children'] = []
+
+        for video in resource_info['children']:
+            warnings = []
+            if not video['license']:
+                warnings.append('no_license_specified')
+            elif video['license'].find("Creative Commons") == -1:
+                warnings.append('closed_license')
+
+            if len(warnings) > 0:
+                videos_with_warnings.append({'video': video, 'warnings': warnings})
+            elif filter:
+                output_video_info['children'].append(video)
+
+        return videos_with_warnings, output_video_info
+
+
+
+
+# YOUTUBE LANGUAGE CODE HELPERS
+################################################################################
+
+def get_language_with_alpha2_fallback(language_code):
+    """
+    Lookup language code `language_code` (string) in the internal language codes,
+    and if that fails, try to map map `language_code` to the internal represention
+    using the `getlang_by_alpha2` helper method.
+    Returns either a le-utils Language object or None if both lookups fail.
+    """
+    # 1. try to lookup `language` using internal representation
+    language_obj = languages.getlang(language_code)
+    # if language_obj not None, we know `language` is a valid language_id in the internal repr.
+    if language_obj is None:
+        # 2. try to match by two-letter ISO code
+        language_obj = languages.getlang_by_alpha2(language_code)
+    return language_obj
+
+
+def is_youtube_subtitle_file_supported_language(language):
+    """
+    Check if the language code `language` (string) is a valid language code in the
+    internal language id format `{primary_code}` or `{primary_code}-{subcode}`
+    ot alternatively if it s YouTube language code that can be mapped to one of
+    the languages in the internal represention.
+    """
+    language_obj = get_language_with_alpha2_fallback(language)
+    if language_obj is None:
+        print('Found unsupported language code {}'.format(language))
+        return False
+    else:
+        return True
+
+
 
 # CONSTANTS for YouTube cache
 ################################################################################
diff --git a/setup.py b/setup.py
index c59eaf9e..7e9208c3 100644
--- a/setup.py
+++ b/setup.py
@@ -18,7 +18,6 @@
     "validators",                             # TODO: check if this is necessary
     "requests_file",
     "beautifulsoup4>=4.6.3,<4.9.0",   # pinned to match versions in le-pycaption
-    "pressurecooker>=0.0.30",
     "selenium==3.0.1",
     "youtube-dl>=2020.6.16.1",
     "html5lib",
@@ -28,16 +27,17 @@
     "mock==2.0.0",
     "pypdf2>=1.26.0",
     "dictdiffer>=0.8.0",
-    "Pillow==5.4.1",
+    "Pillow==8.2.0",
     "colorlog>=4.1.0,<4.2",
     "PyYAML>=5.3.1",
     "Jinja2>=2.10",
-    "chardet==4.0.0"
+    "chardet==4.0.0",
+    "ffmpy>=0.2.2",
+    "pdf2image==1.11.0",
+    "le-pycaption>=2.2.0a1",
+    "EbookLib>=0.17.1",
 ]
 
-test_requirements = [
-    # TODO: put package test requirements here
-]
 
 setup(
     name='ricecooker',
@@ -72,5 +72,4 @@
         'Topic :: Education',
     ],
     test_suite='tests',
-    tests_require=test_requirements
 )
diff --git a/tox.ini b/tox.ini
index 14bd7e22..3073dcd0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,15 +1,15 @@
 [tox]
-envlist = py{3.5,3.6,3.7}
-
-[travis]
-python =
-    3.5: py3.5
-    3.6: py3.6
-    3.7: py3.7
-
+envlist = py3.{6,7,8,9}
 
 [testenv]
+basepython =
+    py3.6: python3.6
+    py3.7: python3.7
+    py3.8: python3.8
+    py3.9: python3.9
+deps =
+    -r{toxinidir}/requirements_test.txt
 setenv =
     PYTHONPATH = {toxinidir}
 commands =
-    py.test --basetemp={envtmpdir}
+    pytest --basetemp={envtmpdir}


Test patch:
diff --git a/.github/workflows/pythontest.yml b/.github/workflows/pythontest.yml
index acd7be51..3dc4f86f 100644
--- a/.github/workflows/pythontest.yml
+++ b/.github/workflows/pythontest.yml
@@ -24,7 +24,7 @@ jobs:
       max-parallel: 5
       matrix:
         os: [windows-latest, ubuntu-latest, macos-latest]
-        python-version: [3.5, 3.6, 3.7]
+        python-version: [3.6, 3.7, 3.8, 3.9]
         include:
         - os: ubuntu-latest
           pippath: ~/.cache/pip
diff --git a/requirements_test.txt b/requirements_test.txt
new file mode 100644
index 00000000..db5c5977
--- /dev/null
+++ b/requirements_test.txt
@@ -0,0 +1,2 @@
+requests-cache==0.4.13
+pytest==6.2.2
diff --git a/tests/media_utils/README.md b/tests/media_utils/README.md
new file mode 100644
index 00000000..4c5d861d
--- /dev/null
+++ b/tests/media_utils/README.md
@@ -0,0 +1,124 @@
+# Media Utils
+Various media processing functions and utilities - vendored from the previously independent pressurecooker library.
+
+
+## Setting up your environment
+
+* [Install ffmpeg](https://ffmpeg.org/) if you don't have it already.
+
+* [Install GhostScript](https://www.ghostscript.com/) if you don't have it already.
+
+* [Install poppler-utils](https://poppler.freedesktop.org/) if you don't have it already
+
+
+## TODO
+ - ricecooker.utils.youtube v2 spec: see https://github.com/learningequality/pressurecooker/issues/32
+ - To handle ExtractorError as permanent failure (do not try to download repeatedly)
+ - Add to  Youtube video API (support for start time)
+   https://www.youtube.com/watch?v=GOJUNJ1o394&t=52
+   End time complicated https://www.youtube.com/embed/WA8sLsM3McU?start=15&end=20
+   but still possible https://stackoverflow.com/questions/4661905/how-to-customize-an-end-time-for-a-youtube-video
+   See https://developers.google.com/youtube/player_parameters
+
+
+
+
+## Converting caption files
+This contains utilities for converting caption files from a few various
+formats into the preferred `VTT` format. The currently supported formats include:
+- [DFXP](https://en.wikipedia.org/wiki/Timed_Text_Markup_Language) 
+- [SAMI](https://en.wikipedia.org/wiki/SAMI)
+- [SCC](http://www.theneitherworld.com/mcpoodle/SCC_TOOLS/DOCS/SCC_FORMAT.HTML)
+- [SRT](https://en.wikipedia.org/wiki/SubRip) 
+- [TTML](https://en.wikipedia.org/wiki/Timed_Text_Markup_Language)
+- [WebVTT or just VTT](https://en.wikipedia.org/wiki/WebVTT)
+
+> Within `ricecooker`, the term "captions" and "subtitles" are used interchangeably. All of the 
+classes and functions handling conversion use the "subtitles" term.  
+
+
+### Language codes
+The `DFXP`, `SAMI`, and `TTML` formats can encapsulate caption contents for multiple languages within one file.
+The `SCC`, `SRT`, and `VTT` formats are generally limited to a single language that isn't defined in
+the file (the `VTT` may be an exception to this rule, but our converters do not detect its language).
+Therefore when converting these files we cannot know what language we are working
+with and must instead use the constant `LANGUAGE_CODE_UNKNOWN` to extract the converted subtitles.
+
+Note also that language codes used within the subtitle files might differ from
+the LE internal language codes defined in `le-utils`.
+
+
+### Creating a converter from a file
+To create a subtitle converter from a local file path, use these commands:
+```python
+from ricecooker.utils.subtitles import build_subtitle_converter_from_file
+
+converter = build_subtitle_converter_from_file('/path/to/file.srt')
+```
+
+### Creating a converter from a string
+If you already have the captions loaded into a string variable,
+you can create the converter like so:
+```python
+from ricecooker.utils.subtitles import build_subtitle_converter
+
+captions_str = ''   # In this example, `captions_str` holds the caption contents
+converter = build_subtitle_converter(captions_str)
+```
+
+
+### Converting captions
+For the `SCC`, `SRT`, and `VTT` subtitles format that do not contain language code info,
+you must refer to the language as the constant `LANGUAGE_CODE_UNKNOWN` at the
+time of extracting the converted subtitles:
+```python
+from ricecooker.utils.subtitles import build_subtitle_converter_from_file
+from ricecooker.utils.subtitles import LANGUAGE_CODE_UNKNOWN
+
+converter = build_subtitle_converter_from_file('/path/to/file.srt')
+
+# Option A: Obtain the contents of the converted VTT file as a string
+output_str = converter.convert(LANGUAGE_CODE_UNKNOWN)
+
+# Option B: Write the converted subtitles to a local path
+converter.write("/path/to/file.vtt", LANGUAGE_CODE_UNKNOWN)
+```
+The `LANGUAGE_CODE_UNKNOWN` constant is the internal representation `pycaption`
+uses to denote subtitles in an unknown language code. This will be the default
+and only language code for `SCC`, `SRT`, and `VTT` subtitle converters.
+
+If you are unsure of the format, but you know the language of the file,
+it is safer to conditionally replace the `LANGUAGE_CODE_UNKNOWN` with that language:
+```python
+from ricecooker.utils.subtitles import build_subtitle_converter_from_file
+from ricecooker.utils.subtitles import LANGUAGE_CODE_UNKNOWN
+
+converter = build_subtitle_converter_from_file('/path/to/file')
+
+# Replace unknown language code if present
+if converter.has_language(LANGUAGE_CODE_UNKNOWN):
+    converter.replace_unknown_language('en')
+    
+assert converter.has_language('en'), 'Must have English after replace'
+
+output_str = converter.convert('en')
+```
+
+An example showing how to handle the subtitle formats like `DFXP`, `SAMI`, and `TTML`,
+which have multiple languages is shown below:
+```python
+from ricecooker.utils.subtitles import build_subtitle_converter_from_file
+from ricecooker.utils.subtitles import LANGUAGE_CODE_UNKNOWN, InvalidSubtitleLanguageError
+
+converter = build_subtitle_converter_from_file('/path/to/file')
+
+for lang_code in converter.get_language_codes():
+    # `some_logic` would be your decisions on whether to use this language
+    if some_logic(lang_code):
+        converter.write("/path/to/file-{}.vtt".format(lang_code), lang_code)
+    elif lang_code == LANGUAGE_CODE_UNKNOWN:
+        raise InvalidSubtitleLanguageError('Unexpected unknown language')
+```
+
+
+
diff --git a/tests/media_utils/__init__.py b/tests/media_utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/media_utils/files/Wilhelm_Scream.mp3 b/tests/media_utils/files/Wilhelm_Scream.mp3
new file mode 100644
index 00000000..516b386f
Binary files /dev/null and b/tests/media_utils/files/Wilhelm_Scream.mp3 differ
diff --git a/tests/media_utils/files/assets/css/empty.css b/tests/media_utils/files/assets/css/empty.css
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/media_utils/files/assets/css/empty2.css b/tests/media_utils/files/assets/css/empty2.css
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/media_utils/files/assets/images/4933759886_098e9acf93_m.jpg b/tests/media_utils/files/assets/images/4933759886_098e9acf93_m.jpg
new file mode 100644
index 00000000..bfd05ac9
Binary files /dev/null and b/tests/media_utils/files/assets/images/4933759886_098e9acf93_m.jpg differ
diff --git a/tests/media_utils/files/assets/images/copyright.txt b/tests/media_utils/files/assets/images/copyright.txt
new file mode 100644
index 00000000..fb3f2a64
--- /dev/null
+++ b/tests/media_utils/files/assets/images/copyright.txt
@@ -0,0 +1,4 @@
+File: 4933759886_098e9acf93_m.jpg
+Source: https://flic.kr/p/8vYNVC
+License: CC BY 2.0
+
diff --git a/tests/media_utils/files/assets/js/empty.js b/tests/media_utils/files/assets/js/empty.js
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/media_utils/files/file_metadata.txt b/tests/media_utils/files/file_metadata.txt
new file mode 100644
index 00000000..98ea73b5
--- /dev/null
+++ b/tests/media_utils/files/file_metadata.txt
@@ -0,0 +1,1 @@
+Wilhelm_Scream.mp3: public domain, retrieved from https://en.wikipedia.org/wiki/File:Wilhelm_Scream.ogg
diff --git a/tests/media_utils/files/generate_thumbnail/sample.epub b/tests/media_utils/files/generate_thumbnail/sample.epub
new file mode 100644
index 00000000..bd11f573
Binary files /dev/null and b/tests/media_utils/files/generate_thumbnail/sample.epub differ
diff --git a/tests/media_utils/files/generate_thumbnail/sample.pdf b/tests/media_utils/files/generate_thumbnail/sample.pdf
new file mode 100644
index 00000000..ac80462d
Binary files /dev/null and b/tests/media_utils/files/generate_thumbnail/sample.pdf differ
diff --git a/tests/media_utils/files/generate_thumbnail/sample.zip b/tests/media_utils/files/generate_thumbnail/sample.zip
new file mode 100644
index 00000000..a607b518
Binary files /dev/null and b/tests/media_utils/files/generate_thumbnail/sample.zip differ
diff --git a/tests/media_utils/files/kepub.epub b/tests/media_utils/files/kepub.epub
new file mode 100644
index 00000000..7c42fcd2
Binary files /dev/null and b/tests/media_utils/files/kepub.epub differ
diff --git a/tests/media_utils/files/page_with_links.html b/tests/media_utils/files/page_with_links.html
new file mode 100644
index 00000000..4cd05206
--- /dev/null
+++ b/tests/media_utils/files/page_with_links.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <link rel="stylesheet" type="text/css" href="assets/css/empty.css" />
+    <link rel="stylesheet" type="text/css" href=assets/css/empty2.css />
+    <script src="assets/js/empty.js?v=2"></script>
+    <title>A Link Between Webs</title>
+</head>
+<body>
+    <header>
+        <a href="page_with_links.html">Home</a> | <a href="#jquery">jQuery Tutorial</a>
+    </header>
+    <img src="assets/images/4933759886_098e9acf93_m.jpg" />
+    <p><a href="the_spanish_inquisition.html#nooneexpects">And now for something completely different...</a></p>
+    <p><a href="http://www.learningequality.org">Learning not equal? We can help!</a></p>
+    <p>This is not a/link/to/index.html</p>
+    <p>http://shouldntbeextracted.com</p>
+    <audio src="Wilhelm_Scream.mp3"></audio>
+    <h1>jQuery for ubernerds, chapter 1.</h1>
+    <p>Importing jQuery via script tag:</p>
+        <blockquote>
+        <pre>
+            &lt;script src="jquery.js"&gt;&lt;/script&gt;
+        </pre>
+
+        </blockquote>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/media_utils/files/subtitles/basic.srt b/tests/media_utils/files/subtitles/basic.srt
new file mode 100644
index 00000000..7155e2dc
--- /dev/null
+++ b/tests/media_utils/files/subtitles/basic.srt
@@ -0,0 +1,985 @@
+1
+00:00:12,464 --> 00:00:14,979
+أمضيت ما يقرب من العقدين
+
+2
+00:00:14,979 --> 00:00:18,532
+ألاحظ ما يجعل البعض أكثر حظًا من غيرهم
+
+3
+00:00:18,536 --> 00:00:22,119
+وأحاول مساعدة الناس على زيادة حظهم.
+
+4
+00:00:22,675 --> 00:00:25,571
+كما ترون، أنا أُدرس ريادة الأعمال،
+
+5
+00:00:25,571 --> 00:00:29,160
+وجميعنا يعلم أن معظم المشاريع الجديدة
+تبوء بالفشل،
+
+6
+00:00:29,160 --> 00:00:32,797
+وكل المستثمرين ورجال الأعمال
+يحاولون الوصول لأكبر قدر من الحظ.
+
+7
+00:00:33,846 --> 00:00:35,156
+لذا، ما هو تعريف الحظ؟
+
+8
+00:00:35,160 --> 00:00:41,200
+يعرف الحظ على أنه النجاح أو الفشل 
+سببه الصدفة على ما يبدو.
+
+9
+00:00:42,295 --> 00:00:43,682
+على ما يبدو.
+
+10
+00:00:43,682 --> 00:00:45,321
+هذه هي الكلمة الأكثر أهمية.
+
+11
+00:00:46,353 --> 00:00:48,382
+فقد يبدو أنه مصادفة
+
+12
+00:00:48,386 --> 00:00:53,564
+لأننا نادرًا ما نرى العوامل
+التي تتدخل لجعل الناس محظوظين.
+
+13
+00:00:53,568 --> 00:00:56,601
+ولكني أدركت عبر تتبعي الطويل
+
+14
+00:00:56,601 --> 00:00:59,912
+أن الحظ نادرًا ما يكون كصاعقة برق
+
+15
+00:00:59,912 --> 00:01:01,696
+منفردًا ودراميًا.
+
+16
+00:01:02,249 --> 00:01:04,629
+فهو أقرب إلى الرياح،
+
+17
+00:01:04,629 --> 00:01:06,225
+التي تهب باستمرار.
+
+18
+00:01:06,249 --> 00:01:07,818
+فأحيانًا تكون هادئة،
+
+19
+00:01:07,822 --> 00:01:09,947
+وأحيانًا أخرى تعصف،
+
+20
+00:01:09,947 --> 00:01:14,222
+وأحيانًا تأتي من اتجاهات 
+لم تكن لتتخيلها.
+
+21
+00:01:14,222 --> 00:01:16,689
+فكيف تتصيد رياح الحظ إذًا؟
+
+22
+00:01:17,616 --> 00:01:20,195
+هذا الأمر سهل، ولكنه ليس واضح.
+
+23
+00:01:20,665 --> 00:01:23,615
+لذا سوف أشارككم ثلاثة أشياءٍ
+
+24
+00:01:23,639 --> 00:01:28,823
+يمكنكم القيام بها لتبنوا شراعًا
+بإمكانه اصطياد رياح الحظ.
+
+25
+00:01:29,651 --> 00:01:31,406
+أول شيء يجب فعله
+
+26
+00:01:31,430 --> 00:01:33,935
+هو أن تغيروا علاقتكم مع أنفسكم.
+
+27
+00:01:33,959 --> 00:01:38,101
+فكونوا مستعدين للمغامرات الصغيرة
+التي ستخرجكم من منطقة راحتكم.
+
+28
+00:01:38,101 --> 00:01:41,491
+حينما كنا أطفالًا، اعتدنا فعل ذلك 
+طوال الوقت.
+
+29
+00:01:42,459 --> 00:01:45,708
+فتحتم علينا فعل ذلك إذا أردنا أن نتعلم
+كيفية المشي أو الكلام
+
+30
+00:01:45,722 --> 00:01:46,900
+أو ركوب الدراجات
+
+31
+00:01:46,904 --> 00:01:48,858
+أو حتى دراسة ميكانيكا الكم، صحيح؟
+
+32
+00:01:48,858 --> 00:01:51,816
+يجب أن تنتقل من شخص لا يعرف 
+كيف يركب دراجة فى أسبوعه الأول
+
+33
+00:01:51,820 --> 00:01:53,603
+إلى آخرٍ يعرف كيف، في أسبوعه التالي.
+
+34
+00:01:53,627 --> 00:01:56,181
+وهذا يتطلب منا الخروج من منطقة راحتنا
+
+35
+00:01:56,205 --> 00:01:58,038
+والقيام ببعض المغامرات.
+
+36
+00:01:58,038 --> 00:02:00,151
+المشكلة تكمن في أنه كلما تقدمنا في السن
+
+37
+00:02:00,165 --> 00:02:02,056
+نادرًا ما نفعل ذلك.
+
+38
+00:02:02,080 --> 00:02:04,331
+فنوعًا ما نحن ندفن إحساسنا بهويتنا
+
+39
+00:02:04,331 --> 00:02:06,149
+ونصبح غير مرنين.
+
+40
+00:02:06,173 --> 00:02:07,771
+الآن مع طلابي،
+
+41
+00:02:07,795 --> 00:02:10,651
+أمضي وقتًا طويلًا في تشجيعهم
+
+42
+00:02:10,675 --> 00:02:13,183
+للخروج من منطقة راحتهم والمخاطرة.
+
+43
+00:02:13,787 --> 00:02:14,952
+فكيف أفعل ذلك؟
+
+44
+00:02:14,976 --> 00:02:18,848
+حسنًا، أبدأ بجعلهم يرسمون مخططًا
+لقياس مدى استعدادهم للمخاطرة.
+
+45
+00:02:18,872 --> 00:02:21,652
+وهو شيء ممتع قمنا بتطويره في الفصل
+
+46
+00:02:21,676 --> 00:02:24,706
+حيث يقومون برسم المخاطر
+المستعدين للقيام بها.
+
+47
+00:02:24,730 --> 00:02:27,654
+وسرعان ما يتضح لهم
+
+48
+00:02:27,658 --> 00:02:29,734
+أن المخاطرة ليست أمرًا ثنائيًا.
+
+49
+00:02:30,242 --> 00:02:33,893
+فهناك مخاطر فكرية، 
+ومخاطر مادية، ومخاطر مالية
+
+50
+00:02:33,897 --> 00:02:37,905
+ومخاطر عاطفية، ومخاطر اجتماعية، 
+ومخاطر أخلاقية، ومخاطر سياسية.
+
+51
+00:02:37,929 --> 00:02:41,714
+وبمجرد الانتهاء من ذلك، يقارنون بيانات 
+المخاطرة الخاصة بهم مع الآخرين.
+
+52
+00:02:41,738 --> 00:02:45,040
+وسرعان ما يدركون أنهم جميعًا
+مختلفون تمامًا.
+
+53
+00:02:46,006 --> 00:02:48,582
+وبعدها أشجعهم على أن يصبحوا أكثر مرونة
+
+54
+00:02:48,586 --> 00:02:51,495
+على أن يخاطروا قليلًا للخروج
+من منطقة راحتهم.
+
+55
+00:02:51,499 --> 00:02:54,864
+فعلى سبيل المثال، يمكن أن أطلب منهم
+القيام بمخاطرة فكرية
+
+56
+00:02:54,864 --> 00:02:57,936
+وأن يحاولوا معالجة مشكلة لم يحاولوا
+حلها من قبل
+
+57
+00:02:57,936 --> 00:03:02,485
+أو مخاطرة اجتماعية كمحادثة الشخص الجالس
+بجوارهم في القطار،
+
+58
+00:03:02,485 --> 00:03:04,240
+أو مخاطرة عاطفية،
+
+59
+00:03:04,240 --> 00:03:07,185
+كإخبار شخص ما بشعورهم.
+
+60
+00:03:08,306 --> 00:03:10,751
+شخصيًّا دائمًا ما أقوم بذلك.
+
+61
+00:03:11,286 --> 00:03:13,691
+منذ حوالي عشر سنوات كنت على متن طائرة
+
+62
+00:03:13,715 --> 00:03:16,476
+في رحلة طيران مبكرة جدًا
+متجهة إلى الإكوادور.
+
+63
+00:03:16,500 --> 00:03:22,222
+في الغالب، كنت سأضع سماعات الرأس
+
+64
+00:03:22,246 --> 00:03:24,177
+وأنام ثم أستيقظ وأنجز بعض المهام،
+
+65
+00:03:24,201 --> 00:03:25,960
+ولكنني قررت أن أُخاطر قليلًا
+
+66
+00:03:25,984 --> 00:03:28,872
+وبدأت في محادثة الرجل الجالس بجواري.
+
+67
+00:03:28,872 --> 00:03:32,073
+فقدمت نفسي، وبعدها علمت أنه ناشر.
+
+68
+00:03:33,695 --> 00:03:34,856
+هذا مثيرٌ للإهتمام.
+
+69
+00:03:34,880 --> 00:03:37,128
+وانتهى الأمر بمحادثة رائعة.
+
+70
+00:03:37,152 --> 00:03:39,965
+عرفت فيها كل ما يخص مستقبل النشر.
+
+71
+00:03:39,969 --> 00:03:43,160
+وبمرور ثلاثة أرباع الرحلة،
+
+72
+00:03:43,160 --> 00:03:44,965
+قررت أن أُخاطر مرةً أخرى،
+
+73
+00:03:44,989 --> 00:03:48,979
+ففتحت حاسوبي المحمول وعرضت عليه كتابًا
+
+74
+00:03:48,979 --> 00:03:52,949
+جمعته من أشياءٍ كنت قد قُمتُ بها في فصلي.
+
+75
+00:03:52,953 --> 00:03:54,697
+وكان مهذبًا للغاية وقرأه،
+
+76
+00:03:54,721 --> 00:03:57,493
+وقال "أتعلمين يا تينا، 
+هذا لن يجدي نفعًا لنا،
+
+77
+00:03:57,507 --> 00:03:59,174
+لكن أشكرك بشدة على المشاركة."
+
+78
+00:03:59,198 --> 00:04:01,231
+لا بأس، فتلك المخاطرة لم تنجح.
+
+79
+00:04:01,745 --> 00:04:03,051
+أغلقت حاسوبي.
+
+80
+00:04:03,051 --> 00:04:06,046
+وفي نهاية الرحلة تبادلنا 
+معلومات الاتصال الخاصة بنا.
+
+81
+00:04:07,594 --> 00:04:10,026
+وبعد شهرين، اتصلت به،
+
+82
+00:04:10,030 --> 00:04:12,676
+وقلت له: "أتحب أن تأتي إلى فصلي يا مارك؟
+
+83
+00:04:12,676 --> 00:04:15,450
+فقد بدأت في مشروع لإعادة ابتكار كتاب
+
+84
+00:04:15,474 --> 00:04:16,803
+عن مستقبل النشر."
+
+85
+00:04:16,807 --> 00:04:18,685
+فرد قائلًا: "رائع، أحب أن أحضر."
+
+86
+00:04:18,685 --> 00:04:21,024
+فأتى إلى فصلي وكانت تجربة رائعة.
+
+87
+00:04:21,024 --> 00:04:23,167
+وبعد بضعة أشهر، راسلته مجددًا.
+
+88
+00:04:23,171 --> 00:04:25,831
+وفي هذه المرة أرسلت له عددًا
+من مقاطع الفيديو
+
+89
+00:04:25,831 --> 00:04:28,099
+من مشروعٍ آخر قام به طلابي.
+
+90
+00:04:28,948 --> 00:04:31,534
+وأُعجب بشدة
+
+91
+00:04:31,534 --> 00:04:33,740
+بأحد المشاريع التي قام بها الطلاب،
+
+92
+00:04:33,744 --> 00:04:35,718
+وفكر أنه يمكن أن يَنتُج عنه كتابًا،
+
+93
+00:04:35,742 --> 00:04:37,885
+وأراد أن يقابل هؤلاء الطلبة.
+
+94
+00:04:38,224 --> 00:04:40,407
+يجب أن أخبركم أني كنت مستاءة بعض الشيء.
+
+95
+00:04:40,431 --> 00:04:41,432
+(ضحك)
+
+96
+00:04:41,456 --> 00:04:44,481
+أعني أنه أراد أن ينشر كتابًا 
+مع طلابي وليس معي.
+
+97
+00:04:44,505 --> 00:04:45,656
+ولكن حسنًا، لا بأس.
+
+98
+00:04:45,680 --> 00:04:49,191
+بعد ذلك دعوته للمجيء، 
+فجاء هو وزملاؤه إلى ستانفورد
+
+99
+00:04:49,191 --> 00:04:52,655
+وقابل الطلاب، وبعدها تناولنا الغداء معًا.
+
+100
+00:04:53,487 --> 00:04:55,303
+وقال لي أحد محرريه:
+
+101
+00:04:55,307 --> 00:04:58,108
+"مهلًا، هل فكرت من قبل في كتابة كتاب؟"
+
+102
+00:04:58,918 --> 00:05:00,745
+فأجبت: "يالها من مصادفة أنك سألت!"
+
+103
+00:05:00,745 --> 00:05:03,874
+وأخرجت عرض الكتاب ذاته
+
+104
+00:05:03,874 --> 00:05:07,256
+الذي عرضته على مديره قبل عام.
+
+105
+00:05:07,256 --> 00:05:09,696
+وفي غضون أسبوعين، وقعت العقد،
+
+106
+00:05:09,710 --> 00:05:13,591
+وخلال عامين، بيعت أكثر من مليون نسخة
+من الكتاب حول العالم.
+
+107
+00:05:14,115 --> 00:05:16,201
+(تصفيق)
+
+108
+00:05:16,225 --> 00:05:18,257
+الآن، يمكنكم قول
+
+109
+00:05:18,281 --> 00:05:19,630
+"أوه! أنتِ محظوظة للغاية"
+
+110
+00:05:19,654 --> 00:05:20,925
+بالطبع كنت محظوظة،
+
+111
+00:05:20,949 --> 00:05:25,273
+ولكن هذا الحظ كان نتيجة سلسلة 
+من مجازفاتٍ صغيرةٍ قمت بها،
+
+112
+00:05:25,297 --> 00:05:27,567
+بدايةً من إلقاء التحية،
+
+113
+00:05:27,591 --> 00:05:29,980
+وبإمكان أي أحد القيام بذلك،
+
+114
+00:05:30,004 --> 00:05:31,767
+أيًا ما كان مكانه في حياتكم،
+
+115
+00:05:31,791 --> 00:05:33,651
+وأيًا ما كان مكانكم في العالم..
+
+116
+00:05:33,675 --> 00:05:36,961
+حتى وإن كنتم تعتقدون أنكم الأسوأ حظًا،
+
+117
+00:05:36,985 --> 00:05:41,284
+يمكنكم فعل ذلك إذا غامرتم قليلًا 
+لتخرجوا من منطقة راحتكم.
+
+118
+00:05:41,284 --> 00:05:43,627
+فتبدؤون في بناء شراع قادر على اصطياد الحظ.
+
+119
+00:05:44,605 --> 00:05:46,348
+الشيء الثاني الذي عليكم فعله
+
+120
+00:05:46,372 --> 00:05:49,081
+هو أن تغيروا علاقتكم مع الآخرين.
+
+121
+00:05:49,105 --> 00:05:54,471
+فيجب أن تفهموا أن كل من يساعدكم في رحلتكم
+
+122
+00:05:54,495 --> 00:05:58,396
+يلعب دورًا كبيرًا في إيصالكم لأهدافكم.
+
+123
+00:05:58,400 --> 00:06:01,011
+وإن لم تظهروا تقديرًا لهؤلاء،
+
+124
+00:06:01,035 --> 00:06:03,354
+فأنتم بذلك لا تسيئون لهم فحسب،
+
+125
+00:06:03,354 --> 00:06:05,082
+بل تضيعون فرصًا أيضًا.
+
+126
+00:06:06,392 --> 00:06:08,454
+فعندما يقدم لكم أحدهم خدمةً،
+
+127
+00:06:08,454 --> 00:06:09,740
+فإن ذلك يكلفهم وقتًا
+
+128
+00:06:09,740 --> 00:06:12,934
+كان من الممكن أن يقضونه لأنفسهم
+أو لأحدٍ آخر،
+
+129
+00:06:12,958 --> 00:06:15,439
+ويجب أن تعترفوا بما يفعلونه.
+
+130
+00:06:16,492 --> 00:06:20,761
+الآن، أنا أدير ثلاثة برامج زمالة 
+في ستانفورد،
+
+131
+00:06:20,785 --> 00:06:22,882
+وهم يتنافسون بشراسة ليتمكنوا من الانضمام
+
+132
+00:06:22,906 --> 00:06:26,147
+وعندما أرسل رسالةً لمن تم استبعادهم 
+من الطلاب،
+
+133
+00:06:26,171 --> 00:06:29,182
+أعرف دائما أن البعض منهم يخيب أملهم.
+
+134
+00:06:29,186 --> 00:06:32,376
+فبعض من يشعر بخيبة الأمل 
+يرسلون لي الملاحظات والشكاوى.
+
+135
+00:06:32,400 --> 00:06:34,003
+وبعضهم يرسل ملاحظات
+
+136
+00:06:34,027 --> 00:06:37,495
+سائلين عما يمكن فعله حتى يصبحوا أكثر
+نجاحًا في المرة القادمة.
+
+137
+00:06:37,499 --> 00:06:38,810
+ومن وقتٍ لآخر،
+
+138
+00:06:38,834 --> 00:06:42,173
+يرسل لي أحدهم ملاحظة يشكرني فيها 
+على الفرصة.
+
+139
+00:06:42,880 --> 00:06:44,596
+حدث هذا قبل سبع سنوات تقريبًا.
+
+140
+00:06:44,620 --> 00:06:47,349
+أرسل لي شابٌ يدعى براين
+ملاحظة جميلة قائلًا:
+
+141
+00:06:47,373 --> 00:06:50,628
+"أعلم أنه تم رفضي من البرنامج مرتين،
+
+142
+00:06:50,652 --> 00:06:52,752
+لكن أريد أن أشكرك على الفرصة.
+
+143
+00:06:52,776 --> 00:06:55,423
+فقد تعلمت الكثير خلال عملية التقديم."
+
+144
+00:06:56,205 --> 00:07:00,080
+أسرني لطف هذه الرسالة
+
+145
+00:07:00,104 --> 00:07:02,190
+لدرجة أني دعوته للمجيء ومقابلتي.
+
+146
+00:07:02,214 --> 00:07:05,001
+وقضينا بعض الوقت نتحدث
+وجهزنا فكرة سويًا
+
+147
+00:07:05,025 --> 00:07:07,048
+لمشروع دراسة مستقل.
+
+148
+00:07:08,072 --> 00:07:10,041
+وقد كان في فريق كرة القدم في ستانفورد،
+
+149
+00:07:10,065 --> 00:07:13,994
+وقرر القيام بمشروع يسلط الضوء على القيادة
+في هذا السياق.
+
+150
+00:07:14,336 --> 00:07:18,005
+تعرفنا على بعضنا البعض بشكل جيد
+خلال هذا العمل،
+
+151
+00:07:18,005 --> 00:07:20,434
+وقد أخذ البرنامج الذي بدأ العمل عليه
+
+152
+00:07:20,438 --> 00:07:21,748
+في الدراسة المستقلة
+
+153
+00:07:21,772 --> 00:07:26,252
+وحوله في النهاية إلى شركة تدعى
+"العب للغد"،
+
+154
+00:07:26,276 --> 00:07:29,247
+حيث يعلم الأطفال المنحدرين من أُسر محرومة
+
+155
+00:07:29,271 --> 00:07:32,077
+كيف يصلون إلى الحياة التي يحلمون بها.
+
+156
+00:07:33,149 --> 00:07:35,998
+الجزء المهم من هذه القصة الآن
+
+157
+00:07:36,022 --> 00:07:39,142
+هو أن المطاف انتهى بكلانا ونحن نحاول
+اصطياد رياح الحظ
+
+158
+00:07:39,166 --> 00:07:41,408
+وكل ذلك نتيجة لملاحظة كتب فيها "شكرا لك".
+
+159
+00:07:41,408 --> 00:07:44,917
+لكنها كانت الرياح التي لم نتوقعها 
+من الأساس.
+
+160
+00:07:46,145 --> 00:07:48,214
+وعلى مدار العامين الماضيين،
+
+161
+00:07:48,218 --> 00:07:51,256
+استخلصت بعض التكتيكات لحياتي الخاصة
+
+162
+00:07:51,256 --> 00:07:53,527
+حتى تساعدني حقًا على ترسيخ فكرة التقدير.
+
+163
+00:07:54,107 --> 00:07:57,655
+والتكتيك المفضل لدي هو أنه 
+في نهاية كل يوم،
+
+164
+00:07:57,679 --> 00:08:01,435
+أنظر إلى تقويمي وأراجع من التقيت بهم،
+
+165
+00:08:01,439 --> 00:08:04,505
+وأرسل ملاحظات شكر إلى كل واحد منهم.
+
+166
+00:08:04,509 --> 00:08:06,703
+لا يتطلب ذلك إلا بعض الدقائق،
+
+167
+00:08:06,703 --> 00:08:08,506
+ولكن بنهاية كل يوم،
+
+168
+00:08:08,530 --> 00:08:11,803
+أشعر بامتنانٍ وتقديرٍعظيمين.
+
+169
+00:08:11,803 --> 00:08:14,343
+وأؤكد لكم أن هذا زاد من حظي.
+
+170
+00:08:15,207 --> 00:08:18,806
+أولًا، فأنتم بحاجة للمجازفة قليلًا
+والخروج من منطقة راحتكم.
+
+171
+00:08:18,806 --> 00:08:21,306
+ثانيًا، أنتم بحاجة للتعبير عن التقدير.
+
+172
+00:08:21,320 --> 00:08:25,216
+وثالثًا، تحتاجون إلى تغييرعلاقتكم
+مع الأفكار.
+
+173
+00:08:26,210 --> 00:08:29,518
+فالمعظم ينظر إلى الأفكار الجديدة
+التي تعترض طريقه ويحكم عليها.
+
+174
+00:08:29,542 --> 00:08:32,298
+فيقولون "هذه فكرة رائعة"
+أو "هذه فكرة شنيعة."
+
+175
+00:08:32,322 --> 00:08:35,306
+لكن الأمور بحاجة لدقة أكتر من ذلك بكثير.
+
+176
+00:08:36,031 --> 00:08:37,898
+فالأفكار ليست جيدة أو سيئة.
+
+177
+00:08:37,898 --> 00:08:42,708
+فى الحقيقة، الأفكار المريعة 
+عادة ما تكون بذورها أشياء رائعة بحق.
+
+178
+00:08:44,039 --> 00:08:47,248
+فواحدة من تماريني المفضلة على الإبداع 
+في فصولي
+
+179
+00:08:47,262 --> 00:08:51,324
+هو مساعدة طلابي على النظر للأفكار السيئة،
+
+180
+00:08:51,348 --> 00:08:54,503
+من خلال نظارات الاحتمالات.
+
+181
+00:08:55,157 --> 00:08:57,294
+لذلك أعطيتهم تحديًا:
+
+182
+00:08:57,318 --> 00:09:00,543
+أن يبتكروا فكرة لمطعم جديد تمامًا.
+
+183
+00:09:01,080 --> 00:09:03,994
+وعليهم أن يبتكروا أفضل الأفكار لمطعم جديد
+
+184
+00:09:04,018 --> 00:09:06,587
+وأسوأ الأفكار لمطعم جديد.
+
+185
+00:09:06,611 --> 00:09:10,070
+فكانت أفضل الأفكار هي اقتراحات كبناء مطعم 
+على قمة جبل
+
+186
+00:09:10,094 --> 00:09:11,629
+مع غروب شمس جميل،
+
+187
+00:09:11,653 --> 00:09:14,549
+أو مطعم على قارب يطل على منظر خلاب.
+
+188
+00:09:14,573 --> 00:09:19,359
+أما عن الأفكار السيئة فهي أشياء مثل 
+بناء مطعم في مكب للقمامة،
+
+189
+00:09:19,383 --> 00:09:23,627
+أو مطعم به خدمة سيئة للغاية،
+
+190
+00:09:23,651 --> 00:09:27,073
+أو مطعم يقدم صرصور السوشي.
+
+191
+00:09:27,097 --> 00:09:28,421
+(ضحك)
+
+192
+00:09:28,445 --> 00:09:30,565
+بعدها قاموا بتسليمي كل الأفكار،
+
+193
+00:09:30,589 --> 00:09:32,832
+فقرأت الأفكار الرائعة بصوتٍ عالٍ،
+
+194
+00:09:32,856 --> 00:09:34,921
+ثم قمت بتمزيقها وإلقائها بعيدًا.
+
+195
+00:09:36,413 --> 00:09:39,555
+وبعدها أخذت الأفكار المريعة
+وقمت بإعادة توزيعها.
+
+196
+00:09:40,159 --> 00:09:44,722
+وأصبح كل فريق معه فكرة ظن الفريق الآخر 
+أنها مريعة،
+
+197
+00:09:44,746 --> 00:09:47,563
+ومهمتهم هي تحويلها إلى شيء رائع.
+
+198
+00:09:48,984 --> 00:09:50,179
+إليكم ما حدث:
+
+199
+00:09:50,904 --> 00:09:55,812
+بمرور حوالي 10 ثوان، قال أحدهم
+"هذه فكرة رائعة!"
+
+200
+00:09:55,836 --> 00:10:00,478
+وكان لديهم ما يقرب من الثلاث دقائق
+حتى ينقلوا الفكرة إلى باقي الفصل.
+
+201
+00:10:00,502 --> 00:10:03,789
+بالنسبة للمطعم الذي في مكب القمامة
+فإلى ماذا قد تحول؟
+
+202
+00:10:03,813 --> 00:10:07,973
+حسنًا، لقد جمعوا كل الطعام الفائض من مطاعم
+"نجمة ميشلان"
+
+203
+00:10:07,997 --> 00:10:10,301
+التي كانت ستُلقى،
+
+204
+00:10:10,325 --> 00:10:12,905
+وبذلك أصبح لديهم مطعم آخر بسعر أقل بكثير،
+
+205
+00:10:12,929 --> 00:10:14,080
+باستخدام كل بقايا الأكل.
+
+206
+00:10:14,104 --> 00:10:15,261
+أليس ذلك رائعًا؟
+
+207
+00:10:15,285 --> 00:10:18,233
+أما عن المطعم صاحب الخدمة السيئة،
+
+208
+00:10:18,689 --> 00:10:22,408
+حسنًا، لقد تحول هذا إلى مطعم 
+بمثابة ساحة تدريب
+
+209
+00:10:22,432 --> 00:10:26,527
+لمطاعم المستقبل حتى تكتشف طريقة
+يمكن بها تجنب كل العقبات.
+
+210
+00:10:27,361 --> 00:10:29,298
+والمطعم الذي يقدم صرصور السوشي؟
+
+211
+00:10:29,322 --> 00:10:31,452
+فقد تحول إلى مطعم للسوشي بالطبع
+
+212
+00:10:31,476 --> 00:10:34,559
+مع جميع أنواع المكونات المثيرة للإهتمام 
+والغريبة حقًا.
+
+213
+00:10:35,922 --> 00:10:39,010
+إذا ألقيتم نظرة على الشركات
+
+214
+00:10:39,034 --> 00:10:42,081
+والابتكارات الحديثة من حولكم
+
+215
+00:10:42,105 --> 00:10:45,731
+التي نعتبرها من المسلمات التي غيرت حياتنا،
+
+216
+00:10:45,755 --> 00:10:47,025
+حسنًا، أتعرفون أمرًا؟
+
+217
+00:10:47,049 --> 00:10:49,930
+لقد بدأوا جميعًا من أفكار مجنونة.
+
+218
+00:10:49,954 --> 00:10:52,683
+لقد بدأوا كأفكارٍ حين مرروها على الآخرين،
+
+219
+00:10:52,707 --> 00:10:55,834
+معظم الناس قالوا: "هذا جنون!
+لن تنجح أبداً "
+
+220
+00:10:58,095 --> 00:11:03,863
+لذا، نعم، أحيانًا يولد البعض في ظروف
+سيئة للغاية،
+
+221
+00:11:03,887 --> 00:11:06,172
+وأحيانًا، يكون الحظ كصاعقة برق
+
+222
+00:11:06,196 --> 00:11:09,156
+تضربنا بشيء رائع أو شيء مريع.
+
+223
+00:11:10,108 --> 00:11:14,060
+لكن رياح الحظ موجودة دائمًا،
+
+224
+00:11:14,084 --> 00:11:17,139
+وإذا كنتم مستعدين للمغامرة قليلًا،
+
+225
+00:11:17,163 --> 00:11:20,671
+إذا كنتم مستعدين للخروج حقًا 
+والتعبير عن التقدير،
+
+226
+00:11:20,695 --> 00:11:25,116
+ومستعدين للنظر بعناية للأفكار 
+حتى وإن كانت مجنونة،
+
+227
+00:11:25,140 --> 00:11:26,909
+بعين الاحتمال،
+
+228
+00:11:26,933 --> 00:11:31,335
+فيمكنكم بناء شراع أكبر فأكبر
+لاصطياد رياح الحظ.
+
+229
+00:11:31,692 --> 00:11:32,843
+شكًرا لكم.
+
+230
+00:11:32,867 --> 00:11:37,915
+(تصفيق)
+
diff --git a/tests/media_utils/files/subtitles/basic.vtt b/tests/media_utils/files/subtitles/basic.vtt
new file mode 100644
index 00000000..08423ace
--- /dev/null
+++ b/tests/media_utils/files/subtitles/basic.vtt
@@ -0,0 +1,756 @@
+WEBVTT
+
+00:12.464 --> 00:14.979
+أمضيت ما يقرب من العقدين
+
+00:14.979 --> 00:18.532
+ألاحظ ما يجعل البعض أكثر حظًا من غيرهم
+
+00:18.536 --> 00:22.119
+وأحاول مساعدة الناس على زيادة حظهم.
+
+00:22.675 --> 00:25.571
+كما ترون، أنا أُدرس ريادة الأعمال،
+
+00:25.571 --> 00:29.160
+وجميعنا يعلم أن معظم المشاريع الجديدة
+تبوء بالفشل،
+
+00:29.160 --> 00:32.797
+وكل المستثمرين ورجال الأعمال
+يحاولون الوصول لأكبر قدر من الحظ.
+
+00:33.846 --> 00:35.156
+لذا، ما هو تعريف الحظ؟
+
+00:35.160 --> 00:41.200
+يعرف الحظ على أنه النجاح أو الفشل 
+سببه الصدفة على ما يبدو.
+
+00:42.295 --> 00:43.682
+على ما يبدو.
+
+00:43.682 --> 00:45.321
+هذه هي الكلمة الأكثر أهمية.
+
+00:46.353 --> 00:48.382
+فقد يبدو أنه مصادفة
+
+00:48.386 --> 00:53.564
+لأننا نادرًا ما نرى العوامل
+التي تتدخل لجعل الناس محظوظين.
+
+00:53.568 --> 00:56.601
+ولكني أدركت عبر تتبعي الطويل
+
+00:56.601 --> 00:59.912
+أن الحظ نادرًا ما يكون كصاعقة برق
+
+00:59.912 --> 01:01.696
+منفردًا ودراميًا.
+
+01:02.249 --> 01:04.629
+فهو أقرب إلى الرياح،
+
+01:04.629 --> 01:06.225
+التي تهب باستمرار.
+
+01:06.249 --> 01:07.818
+فأحيانًا تكون هادئة،
+
+01:07.822 --> 01:09.947
+وأحيانًا أخرى تعصف،
+
+01:09.947 --> 01:14.222
+وأحيانًا تأتي من اتجاهات 
+لم تكن لتتخيلها.
+
+01:14.222 --> 01:16.689
+فكيف تتصيد رياح الحظ إذًا؟
+
+01:17.616 --> 01:20.195
+هذا الأمر سهل، ولكنه ليس واضح.
+
+01:20.665 --> 01:23.615
+لذا سوف أشارككم ثلاثة أشياءٍ
+
+01:23.639 --> 01:28.823
+يمكنكم القيام بها لتبنوا شراعًا
+بإمكانه اصطياد رياح الحظ.
+
+01:29.651 --> 01:31.406
+أول شيء يجب فعله
+
+01:31.430 --> 01:33.935
+هو أن تغيروا علاقتكم مع أنفسكم.
+
+01:33.959 --> 01:38.101
+فكونوا مستعدين للمغامرات الصغيرة
+التي ستخرجكم من منطقة راحتكم.
+
+01:38.101 --> 01:41.491
+حينما كنا أطفالًا، اعتدنا فعل ذلك 
+طوال الوقت.
+
+01:42.459 --> 01:45.708
+فتحتم علينا فعل ذلك إذا أردنا أن نتعلم
+كيفية المشي أو الكلام
+
+01:45.722 --> 01:46.900
+أو ركوب الدراجات
+
+01:46.904 --> 01:48.858
+أو حتى دراسة ميكانيكا الكم، صحيح؟
+
+01:48.858 --> 01:51.816
+يجب أن تنتقل من شخص لا يعرف 
+كيف يركب دراجة فى أسبوعه الأول
+
+01:51.820 --> 01:53.603
+إلى آخرٍ يعرف كيف، في أسبوعه التالي.
+
+01:53.627 --> 01:56.181
+وهذا يتطلب منا الخروج من منطقة راحتنا
+
+01:56.205 --> 01:58.038
+والقيام ببعض المغامرات.
+
+01:58.038 --> 02:00.151
+المشكلة تكمن في أنه كلما تقدمنا في السن
+
+02:00.165 --> 02:02.056
+نادرًا ما نفعل ذلك.
+
+02:02.080 --> 02:04.331
+فنوعًا ما نحن ندفن إحساسنا بهويتنا
+
+02:04.331 --> 02:06.149
+ونصبح غير مرنين.
+
+02:06.173 --> 02:07.771
+الآن مع طلابي،
+
+02:07.795 --> 02:10.651
+أمضي وقتًا طويلًا في تشجيعهم
+
+02:10.675 --> 02:13.183
+للخروج من منطقة راحتهم والمخاطرة.
+
+02:13.787 --> 02:14.952
+فكيف أفعل ذلك؟
+
+02:14.976 --> 02:18.848
+حسنًا، أبدأ بجعلهم يرسمون مخططًا
+لقياس مدى استعدادهم للمخاطرة.
+
+02:18.872 --> 02:21.652
+وهو شيء ممتع قمنا بتطويره في الفصل
+
+02:21.676 --> 02:24.706
+حيث يقومون برسم المخاطر
+المستعدين للقيام بها.
+
+02:24.730 --> 02:27.654
+وسرعان ما يتضح لهم
+
+02:27.658 --> 02:29.734
+أن المخاطرة ليست أمرًا ثنائيًا.
+
+02:30.242 --> 02:33.893
+فهناك مخاطر فكرية، 
+ومخاطر مادية، ومخاطر مالية
+
+02:33.897 --> 02:37.905
+ومخاطر عاطفية، ومخاطر اجتماعية، 
+ومخاطر أخلاقية، ومخاطر سياسية.
+
+02:37.929 --> 02:41.714
+وبمجرد الانتهاء من ذلك، يقارنون بيانات 
+المخاطرة الخاصة بهم مع الآخرين.
+
+02:41.738 --> 02:45.040
+وسرعان ما يدركون أنهم جميعًا
+مختلفون تمامًا.
+
+02:46.006 --> 02:48.582
+وبعدها أشجعهم على أن يصبحوا أكثر مرونة
+
+02:48.586 --> 02:51.495
+على أن يخاطروا قليلًا للخروج
+من منطقة راحتهم.
+
+02:51.499 --> 02:54.864
+فعلى سبيل المثال، يمكن أن أطلب منهم
+القيام بمخاطرة فكرية
+
+02:54.864 --> 02:57.936
+وأن يحاولوا معالجة مشكلة لم يحاولوا
+حلها من قبل
+
+02:57.936 --> 03:02.485
+أو مخاطرة اجتماعية كمحادثة الشخص الجالس
+بجوارهم في القطار،
+
+03:02.485 --> 03:04.240
+أو مخاطرة عاطفية،
+
+03:04.240 --> 03:07.185
+كإخبار شخص ما بشعورهم.
+
+03:08.306 --> 03:10.751
+شخصيًّا دائمًا ما أقوم بذلك.
+
+03:11.286 --> 03:13.691
+منذ حوالي عشر سنوات كنت على متن طائرة
+
+03:13.715 --> 03:16.476
+في رحلة طيران مبكرة جدًا
+متجهة إلى الإكوادور.
+
+03:16.500 --> 03:22.222
+في الغالب، كنت سأضع سماعات الرأس
+
+03:22.246 --> 03:24.177
+وأنام ثم أستيقظ وأنجز بعض المهام،
+
+03:24.201 --> 03:25.960
+ولكنني قررت أن أُخاطر قليلًا
+
+03:25.984 --> 03:28.872
+وبدأت في محادثة الرجل الجالس بجواري.
+
+03:28.872 --> 03:32.073
+فقدمت نفسي، وبعدها علمت أنه ناشر.
+
+03:33.695 --> 03:34.856
+هذا مثيرٌ للإهتمام.
+
+03:34.880 --> 03:37.128
+وانتهى الأمر بمحادثة رائعة.
+
+03:37.152 --> 03:39.965
+عرفت فيها كل ما يخص مستقبل النشر.
+
+03:39.969 --> 03:43.160
+وبمرور ثلاثة أرباع الرحلة،
+
+03:43.160 --> 03:44.965
+قررت أن أُخاطر مرةً أخرى،
+
+03:44.989 --> 03:48.979
+ففتحت حاسوبي المحمول وعرضت عليه كتابًا
+
+03:48.979 --> 03:52.949
+جمعته من أشياءٍ كنت قد قُمتُ بها في فصلي.
+
+03:52.953 --> 03:54.697
+وكان مهذبًا للغاية وقرأه،
+
+03:54.721 --> 03:57.493
+وقال "أتعلمين يا تينا، 
+هذا لن يجدي نفعًا لنا،
+
+03:57.507 --> 03:59.174
+لكن أشكرك بشدة على المشاركة."
+
+03:59.198 --> 04:01.231
+لا بأس، فتلك المخاطرة لم تنجح.
+
+04:01.745 --> 04:03.051
+أغلقت حاسوبي.
+
+04:03.051 --> 04:06.046
+وفي نهاية الرحلة تبادلنا 
+معلومات الاتصال الخاصة بنا.
+
+04:07.594 --> 04:10.026
+وبعد شهرين، اتصلت به،
+
+04:10.030 --> 04:12.676
+وقلت له: "أتحب أن تأتي إلى فصلي يا مارك؟
+
+04:12.676 --> 04:15.450
+فقد بدأت في مشروع لإعادة ابتكار كتاب
+
+04:15.474 --> 04:16.803
+عن مستقبل النشر."
+
+04:16.807 --> 04:18.685
+فرد قائلًا: "رائع، أحب أن أحضر."
+
+04:18.685 --> 04:21.024
+فأتى إلى فصلي وكانت تجربة رائعة.
+
+04:21.024 --> 04:23.167
+وبعد بضعة أشهر، راسلته مجددًا.
+
+04:23.171 --> 04:25.831
+وفي هذه المرة أرسلت له عددًا
+من مقاطع الفيديو
+
+04:25.831 --> 04:28.099
+من مشروعٍ آخر قام به طلابي.
+
+04:28.948 --> 04:31.534
+وأُعجب بشدة
+
+04:31.534 --> 04:33.740
+بأحد المشاريع التي قام بها الطلاب،
+
+04:33.744 --> 04:35.718
+وفكر أنه يمكن أن يَنتُج عنه كتابًا،
+
+04:35.742 --> 04:37.885
+وأراد أن يقابل هؤلاء الطلبة.
+
+04:38.224 --> 04:40.407
+يجب أن أخبركم أني كنت مستاءة بعض الشيء.
+
+04:40.431 --> 04:41.432
+(ضحك)
+
+04:41.456 --> 04:44.481
+أعني أنه أراد أن ينشر كتابًا 
+مع طلابي وليس معي.
+
+04:44.505 --> 04:45.656
+ولكن حسنًا، لا بأس.
+
+04:45.680 --> 04:49.191
+بعد ذلك دعوته للمجيء، 
+فجاء هو وزملاؤه إلى ستانفورد
+
+04:49.191 --> 04:52.655
+وقابل الطلاب، وبعدها تناولنا الغداء معًا.
+
+04:53.487 --> 04:55.303
+وقال لي أحد محرريه:
+
+04:55.307 --> 04:58.108
+"مهلًا، هل فكرت من قبل في كتابة كتاب؟"
+
+04:58.918 --> 05:00.745
+فأجبت: "يالها من مصادفة أنك سألت!"
+
+05:00.745 --> 05:03.874
+وأخرجت عرض الكتاب ذاته
+
+05:03.874 --> 05:07.256
+الذي عرضته على مديره قبل عام.
+
+05:07.256 --> 05:09.696
+وفي غضون أسبوعين، وقعت العقد،
+
+05:09.710 --> 05:13.591
+وخلال عامين، بيعت أكثر من مليون نسخة
+من الكتاب حول العالم.
+
+05:14.115 --> 05:16.201
+(تصفيق)
+
+05:16.225 --> 05:18.257
+الآن، يمكنكم قول
+
+05:18.281 --> 05:19.630
+"أوه! أنتِ محظوظة للغاية"
+
+05:19.654 --> 05:20.925
+بالطبع كنت محظوظة،
+
+05:20.949 --> 05:25.273
+ولكن هذا الحظ كان نتيجة سلسلة 
+من مجازفاتٍ صغيرةٍ قمت بها،
+
+05:25.297 --> 05:27.567
+بدايةً من إلقاء التحية،
+
+05:27.591 --> 05:29.980
+وبإمكان أي أحد القيام بذلك،
+
+05:30.004 --> 05:31.767
+أيًا ما كان مكانه في حياتكم،
+
+05:31.791 --> 05:33.651
+وأيًا ما كان مكانكم في العالم..
+
+05:33.675 --> 05:36.961
+حتى وإن كنتم تعتقدون أنكم الأسوأ حظًا،
+
+05:36.985 --> 05:41.284
+يمكنكم فعل ذلك إذا غامرتم قليلًا 
+لتخرجوا من منطقة راحتكم.
+
+05:41.284 --> 05:43.627
+فتبدؤون في بناء شراع قادر على اصطياد الحظ.
+
+05:44.605 --> 05:46.348
+الشيء الثاني الذي عليكم فعله
+
+05:46.372 --> 05:49.081
+هو أن تغيروا علاقتكم مع الآخرين.
+
+05:49.105 --> 05:54.471
+فيجب أن تفهموا أن كل من يساعدكم في رحلتكم
+
+05:54.495 --> 05:58.396
+يلعب دورًا كبيرًا في إيصالكم لأهدافكم.
+
+05:58.400 --> 06:01.011
+وإن لم تظهروا تقديرًا لهؤلاء،
+
+06:01.035 --> 06:03.354
+فأنتم بذلك لا تسيئون لهم فحسب،
+
+06:03.354 --> 06:05.082
+بل تضيعون فرصًا أيضًا.
+
+06:06.392 --> 06:08.454
+فعندما يقدم لكم أحدهم خدمةً،
+
+06:08.454 --> 06:09.740
+فإن ذلك يكلفهم وقتًا
+
+06:09.740 --> 06:12.934
+كان من الممكن أن يقضونه لأنفسهم
+أو لأحدٍ آخر،
+
+06:12.958 --> 06:15.439
+ويجب أن تعترفوا بما يفعلونه.
+
+06:16.492 --> 06:20.761
+الآن، أنا أدير ثلاثة برامج زمالة 
+في ستانفورد،
+
+06:20.785 --> 06:22.882
+وهم يتنافسون بشراسة ليتمكنوا من الانضمام
+
+06:22.906 --> 06:26.147
+وعندما أرسل رسالةً لمن تم استبعادهم 
+من الطلاب،
+
+06:26.171 --> 06:29.182
+أعرف دائما أن البعض منهم يخيب أملهم.
+
+06:29.186 --> 06:32.376
+فبعض من يشعر بخيبة الأمل 
+يرسلون لي الملاحظات والشكاوى.
+
+06:32.400 --> 06:34.003
+وبعضهم يرسل ملاحظات
+
+06:34.027 --> 06:37.495
+سائلين عما يمكن فعله حتى يصبحوا أكثر
+نجاحًا في المرة القادمة.
+
+06:37.499 --> 06:38.810
+ومن وقتٍ لآخر،
+
+06:38.834 --> 06:42.173
+يرسل لي أحدهم ملاحظة يشكرني فيها 
+على الفرصة.
+
+06:42.880 --> 06:44.596
+حدث هذا قبل سبع سنوات تقريبًا.
+
+06:44.620 --> 06:47.349
+أرسل لي شابٌ يدعى براين
+ملاحظة جميلة قائلًا:
+
+06:47.373 --> 06:50.628
+"أعلم أنه تم رفضي من البرنامج مرتين،
+
+06:50.652 --> 06:52.752
+لكن أريد أن أشكرك على الفرصة.
+
+06:52.776 --> 06:55.423
+فقد تعلمت الكثير خلال عملية التقديم."
+
+06:56.205 --> 07:00.080
+أسرني لطف هذه الرسالة
+
+07:00.104 --> 07:02.190
+لدرجة أني دعوته للمجيء ومقابلتي.
+
+07:02.214 --> 07:05.001
+وقضينا بعض الوقت نتحدث
+وجهزنا فكرة سويًا
+
+07:05.025 --> 07:07.048
+لمشروع دراسة مستقل.
+
+07:08.072 --> 07:10.041
+وقد كان في فريق كرة القدم في ستانفورد،
+
+07:10.065 --> 07:13.994
+وقرر القيام بمشروع يسلط الضوء على القيادة
+في هذا السياق.
+
+07:14.336 --> 07:18.005
+تعرفنا على بعضنا البعض بشكل جيد
+خلال هذا العمل،
+
+07:18.005 --> 07:20.434
+وقد أخذ البرنامج الذي بدأ العمل عليه
+
+07:20.438 --> 07:21.748
+في الدراسة المستقلة
+
+07:21.772 --> 07:26.252
+وحوله في النهاية إلى شركة تدعى
+"العب للغد"،
+
+07:26.276 --> 07:29.247
+حيث يعلم الأطفال المنحدرين من أُسر محرومة
+
+07:29.271 --> 07:32.077
+كيف يصلون إلى الحياة التي يحلمون بها.
+
+07:33.149 --> 07:35.998
+الجزء المهم من هذه القصة الآن
+
+07:36.022 --> 07:39.142
+هو أن المطاف انتهى بكلانا ونحن نحاول
+اصطياد رياح الحظ
+
+07:39.166 --> 07:41.408
+وكل ذلك نتيجة لملاحظة كتب فيها "شكرا لك".
+
+07:41.408 --> 07:44.917
+لكنها كانت الرياح التي لم نتوقعها 
+من الأساس.
+
+07:46.145 --> 07:48.214
+وعلى مدار العامين الماضيين،
+
+07:48.218 --> 07:51.256
+استخلصت بعض التكتيكات لحياتي الخاصة
+
+07:51.256 --> 07:53.527
+حتى تساعدني حقًا على ترسيخ فكرة التقدير.
+
+07:54.107 --> 07:57.655
+والتكتيك المفضل لدي هو أنه 
+في نهاية كل يوم،
+
+07:57.679 --> 08:01.435
+أنظر إلى تقويمي وأراجع من التقيت بهم،
+
+08:01.439 --> 08:04.505
+وأرسل ملاحظات شكر إلى كل واحد منهم.
+
+08:04.509 --> 08:06.703
+لا يتطلب ذلك إلا بعض الدقائق،
+
+08:06.703 --> 08:08.506
+ولكن بنهاية كل يوم،
+
+08:08.530 --> 08:11.803
+أشعر بامتنانٍ وتقديرٍعظيمين.
+
+08:11.803 --> 08:14.343
+وأؤكد لكم أن هذا زاد من حظي.
+
+08:15.207 --> 08:18.806
+أولًا، فأنتم بحاجة للمجازفة قليلًا
+والخروج من منطقة راحتكم.
+
+08:18.806 --> 08:21.306
+ثانيًا، أنتم بحاجة للتعبير عن التقدير.
+
+08:21.320 --> 08:25.216
+وثالثًا، تحتاجون إلى تغييرعلاقتكم
+مع الأفكار.
+
+08:26.210 --> 08:29.518
+فالمعظم ينظر إلى الأفكار الجديدة
+التي تعترض طريقه ويحكم عليها.
+
+08:29.542 --> 08:32.298
+فيقولون "هذه فكرة رائعة"
+أو "هذه فكرة شنيعة."
+
+08:32.322 --> 08:35.306
+لكن الأمور بحاجة لدقة أكتر من ذلك بكثير.
+
+08:36.031 --> 08:37.898
+فالأفكار ليست جيدة أو سيئة.
+
+08:37.898 --> 08:42.708
+فى الحقيقة، الأفكار المريعة 
+عادة ما تكون بذورها أشياء رائعة بحق.
+
+08:44.039 --> 08:47.248
+فواحدة من تماريني المفضلة على الإبداع 
+في فصولي
+
+08:47.262 --> 08:51.324
+هو مساعدة طلابي على النظر للأفكار السيئة،
+
+08:51.348 --> 08:54.503
+من خلال نظارات الاحتمالات.
+
+08:55.157 --> 08:57.294
+لذلك أعطيتهم تحديًا:
+
+08:57.318 --> 09:00.543
+أن يبتكروا فكرة لمطعم جديد تمامًا.
+
+09:01.080 --> 09:03.994
+وعليهم أن يبتكروا أفضل الأفكار لمطعم جديد
+
+09:04.018 --> 09:06.587
+وأسوأ الأفكار لمطعم جديد.
+
+09:06.611 --> 09:10.070
+فكانت أفضل الأفكار هي اقتراحات كبناء مطعم 
+على قمة جبل
+
+09:10.094 --> 09:11.629
+مع غروب شمس جميل،
+
+09:11.653 --> 09:14.549
+أو مطعم على قارب يطل على منظر خلاب.
+
+09:14.573 --> 09:19.359
+أما عن الأفكار السيئة فهي أشياء مثل 
+بناء مطعم في مكب للقمامة،
+
+09:19.383 --> 09:23.627
+أو مطعم به خدمة سيئة للغاية،
+
+09:23.651 --> 09:27.073
+أو مطعم يقدم صرصور السوشي.
+
+09:27.097 --> 09:28.421
+(ضحك)
+
+09:28.445 --> 09:30.565
+بعدها قاموا بتسليمي كل الأفكار،
+
+09:30.589 --> 09:32.832
+فقرأت الأفكار الرائعة بصوتٍ عالٍ،
+
+09:32.856 --> 09:34.921
+ثم قمت بتمزيقها وإلقائها بعيدًا.
+
+09:36.413 --> 09:39.555
+وبعدها أخذت الأفكار المريعة
+وقمت بإعادة توزيعها.
+
+09:40.159 --> 09:44.722
+وأصبح كل فريق معه فكرة ظن الفريق الآخر 
+أنها مريعة،
+
+09:44.746 --> 09:47.563
+ومهمتهم هي تحويلها إلى شيء رائع.
+
+09:48.984 --> 09:50.179
+إليكم ما حدث:
+
+09:50.904 --> 09:55.812
+بمرور حوالي 10 ثوان، قال أحدهم
+"هذه فكرة رائعة!"
+
+09:55.836 --> 10:00.478
+وكان لديهم ما يقرب من الثلاث دقائق
+حتى ينقلوا الفكرة إلى باقي الفصل.
+
+10:00.502 --> 10:03.789
+بالنسبة للمطعم الذي في مكب القمامة
+فإلى ماذا قد تحول؟
+
+10:03.813 --> 10:07.973
+حسنًا، لقد جمعوا كل الطعام الفائض من مطاعم
+"نجمة ميشلان"
+
+10:07.997 --> 10:10.301
+التي كانت ستُلقى،
+
+10:10.325 --> 10:12.905
+وبذلك أصبح لديهم مطعم آخر بسعر أقل بكثير،
+
+10:12.929 --> 10:14.080
+باستخدام كل بقايا الأكل.
+
+10:14.104 --> 10:15.261
+أليس ذلك رائعًا؟
+
+10:15.285 --> 10:18.233
+أما عن المطعم صاحب الخدمة السيئة،
+
+10:18.689 --> 10:22.408
+حسنًا، لقد تحول هذا إلى مطعم 
+بمثابة ساحة تدريب
+
+10:22.432 --> 10:26.527
+لمطاعم المستقبل حتى تكتشف طريقة
+يمكن بها تجنب كل العقبات.
+
+10:27.361 --> 10:29.298
+والمطعم الذي يقدم صرصور السوشي؟
+
+10:29.322 --> 10:31.452
+فقد تحول إلى مطعم للسوشي بالطبع
+
+10:31.476 --> 10:34.559
+مع جميع أنواع المكونات المثيرة للإهتمام 
+والغريبة حقًا.
+
+10:35.922 --> 10:39.010
+إذا ألقيتم نظرة على الشركات
+
+10:39.034 --> 10:42.081
+والابتكارات الحديثة من حولكم
+
+10:42.105 --> 10:45.731
+التي نعتبرها من المسلمات التي غيرت حياتنا،
+
+10:45.755 --> 10:47.025
+حسنًا، أتعرفون أمرًا؟
+
+10:47.049 --> 10:49.930
+لقد بدأوا جميعًا من أفكار مجنونة.
+
+10:49.954 --> 10:52.683
+لقد بدأوا كأفكارٍ حين مرروها على الآخرين،
+
+10:52.707 --> 10:55.834
+معظم الناس قالوا: "هذا جنون!
+لن تنجح أبداً "
+
+10:58.095 --> 11:03.863
+لذا، نعم، أحيانًا يولد البعض في ظروف
+سيئة للغاية،
+
+11:03.887 --> 11:06.172
+وأحيانًا، يكون الحظ كصاعقة برق
+
+11:06.196 --> 11:09.156
+تضربنا بشيء رائع أو شيء مريع.
+
+11:10.108 --> 11:14.060
+لكن رياح الحظ موجودة دائمًا،
+
+11:14.084 --> 11:17.139
+وإذا كنتم مستعدين للمغامرة قليلًا،
+
+11:17.163 --> 11:20.671
+إذا كنتم مستعدين للخروج حقًا 
+والتعبير عن التقدير،
+
+11:20.695 --> 11:25.116
+ومستعدين للنظر بعناية للأفكار 
+حتى وإن كانت مجنونة،
+
+11:25.140 --> 11:26.909
+بعين الاحتمال،
+
+11:26.933 --> 11:31.335
+فيمكنكم بناء شراع أكبر فأكبر
+لاصطياد رياح الحظ.
+
+11:31.692 --> 11:32.843
+شكًرا لكم.
+
+11:32.867 --> 11:37.915
+(تصفيق)
diff --git a/tests/media_utils/files/subtitles/empty.ttml b/tests/media_utils/files/subtitles/empty.ttml
new file mode 100644
index 00000000..eeeaf3bd
--- /dev/null
+++ b/tests/media_utils/files/subtitles/empty.ttml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<tt xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:tts="http://www.w3.org/2006/04/ttaf1#styling"
+      xml:lang="en">
+  <head>
+    <styling>
+      <style id="defaultSpeaker" tts:fontSize="12px" tts:fontFamily="SansSerif" tts:fontWeight="normal" tts:fontStyle="normal" tts:textDecoration="none" tts:color="white" tts:backgroundColor="black" tts:textAlign="left" />
+      <style id="defaultCaption" tts:fontSize="12px" tts:fontFamily="SansSerif" tts:fontWeight="normal" tts:fontStyle="normal" tts:textDecoration="none" tts:color="white" tts:backgroundColor="black" tts:textAlign="left" />
+    </styling>
+  </head>
+  <body id="thebody" style="defaultCaption">
+    <div xml:lang="en"/>
+  </body>
+</tt>
\ No newline at end of file
diff --git a/tests/media_utils/files/subtitles/encapsulated.sami b/tests/media_utils/files/subtitles/encapsulated.sami
new file mode 100644
index 00000000..64c66ed4
--- /dev/null
+++ b/tests/media_utils/files/subtitles/encapsulated.sami
@@ -0,0 +1,44 @@
+<SAMI><HEAD><TITLE>NOVA3213</TITLE><STYLE TYPE="text/css">
+<!--
+P { margin-left:  1pt;
+    margin-right: 1pt;
+    margin-bottom: 2pt;
+    margin-top: 2pt;
+    text-align: center;
+    font-size: 10pt;
+    font-family: Arial;
+    font-weight: normal;
+    font-style: normal;
+    color: #ffeedd; }
+.ENCC {Name: English; lang: en; SAMI_Type: CC;}
+--></STYLE></HEAD><BODY>
+<SYNC start="9209"><P class="ENCC">
+       ( clock ticking )
+</P></SYNC>
+<SYNC start="12312"><P class="ENCC">&nbsp;</P></SYNC>
+<SYNC start="14848"><P class="ENCC">
+    MAN:<br/>
+    When we think<br/>
+    \u266a ...say bow, wow, \u266a
+</P></SYNC>
+<SYNC start="17000"><P class="ENCC">
+  <SPAN Style="text-align:right;">we have this vision of Einstein</SPAN>
+</P></SYNC>
+<SYNC start="18752"><P class="ENCC">
+    <br/>
+    as an old, wrinkly man<br/>
+    with white hair.
+</P></SYNC>
+<SYNC start="20887"><P class="ENCC">
+    MAN 2:<br/>
+    E equals m c-squared is<br/>
+    not about an old Einstein.
+</P></SYNC>
+<SYNC start="26760"><P class="ENCC">
+    MAN 2:<br/>
+    It's all about an eternal Einstein.
+</P></SYNC>
+<SYNC start="32200"><P class="ENCC">
+    &lt;LAUGHING &amp; WHOOPS!&gt;
+</P></SYNC>
+</BODY></SAMI>
\ No newline at end of file
diff --git a/tests/media_utils/files/subtitles/encapsulated.vtt b/tests/media_utils/files/subtitles/encapsulated.vtt
new file mode 100644
index 00000000..f3f533ce
--- /dev/null
+++ b/tests/media_utils/files/subtitles/encapsulated.vtt
@@ -0,0 +1,29 @@
+WEBVTT
+
+00:09.209 --> 00:12.312
+( clock ticking )
+
+00:14.848 --> 00:17.000
+MAN:
+When we think
+\u266a ...say bow, wow, \u266a
+
+00:17.000 --> 00:18.752 align:right
+we have this vision of Einstein
+
+00:18.752 --> 00:20.887
+&nbsp;
+as an old, wrinkly man
+with white hair.
+
+00:20.887 --> 00:26.760
+MAN 2:
+E equals m c-squared is
+not about an old Einstein.
+
+00:26.760 --> 00:32.200
+MAN 2:
+It's all about an eternal Einstein.
+
+00:32.200 --> 00:36.200
+&lt;LAUGHING &amp; WHOOPS!>
diff --git a/tests/media_utils/files/subtitles/not.txt b/tests/media_utils/files/subtitles/not.txt
new file mode 100644
index 00000000..f81d8613
--- /dev/null
+++ b/tests/media_utils/files/subtitles/not.txt
@@ -0,0 +1,1 @@
+This file doesn't contain subtitles nor isn't it a subtitle format.
\ No newline at end of file
diff --git a/tests/media_utils/files/thumbnails/BRAlogo1.png b/tests/media_utils/files/thumbnails/BRAlogo1.png
new file mode 100644
index 00000000..5d67df87
Binary files /dev/null and b/tests/media_utils/files/thumbnails/BRAlogo1.png differ
diff --git a/tests/media_utils/files/thumbnails/toosquare.png b/tests/media_utils/files/thumbnails/toosquare.png
new file mode 100644
index 00000000..fb2582f6
Binary files /dev/null and b/tests/media_utils/files/thumbnails/toosquare.png differ
diff --git a/tests/media_utils/files/thumbnails/tootall.png b/tests/media_utils/files/thumbnails/tootall.png
new file mode 100644
index 00000000..466dd2b8
Binary files /dev/null and b/tests/media_utils/files/thumbnails/tootall.png differ
diff --git a/tests/media_utils/files/thumbnails/toowide.png b/tests/media_utils/files/thumbnails/toowide.png
new file mode 100644
index 00000000..3c1fc2b7
Binary files /dev/null and b/tests/media_utils/files/thumbnails/toowide.png differ
diff --git a/tests/media_utils/test_proxy.py b/tests/media_utils/test_proxy.py
new file mode 100644
index 00000000..1b28590e
--- /dev/null
+++ b/tests/media_utils/test_proxy.py
@@ -0,0 +1,59 @@
+import os
+
+import pytest
+
+from ricecooker.utils import proxy
+from ricecooker.utils.youtube import YouTubeResource
+
+
+YOUTUBE_TEST_VIDEO = 'https://www.youtube.com/watch?v=C0DPdy98e4c'
+YOUTUBE_TEST_PLAYLIST = 'https://www.youtube.com/playlist?list=PL472BC6F4F2C3ABEF'
+
+
+# This test takes a few minutes, but is very useful for checking that the proxy is not being ignored,
+# so mark it to run when the PYTEST_RUN_SLOW env var is set.
+@pytest.mark.skipif(not 'PYTEST_RUN_SLOW' in os.environ, reason="This test takes several minutes to complete.")
+def test_bad_proxies_get_banned(tmp_path):
+    # create some fake proxies...
+    FAKE_PROXIES = [
+        '123.123.123.123:1234',
+        '142.123.1.234:123345',
+        '156.245.233.211:12323',
+        '11.22.33.44:123',
+    ]
+    # initialize PROXY_LIST to known-bad proxies to check that they get banned
+    proxy.PROXY_LIST = FAKE_PROXIES.copy()
+
+    video = YouTubeResource(YOUTUBE_TEST_VIDEO)
+    video.download(tmp_path)
+
+    # Fake proxies should get added to BROKEN_PROXIES
+    assert set(FAKE_PROXIES).issubset(set(proxy.BROKEN_PROXIES))
+
+
+@pytest.mark.skipif(not 'PYTEST_RUN_SLOW' in os.environ, reason="This test can take several minutes to complete.")
+def test_proxy_download(tmp_path):
+    proxy.get_proxies(refresh=True)
+    assert len(proxy.PROXY_LIST) > 1
+
+    video = YouTubeResource(YOUTUBE_TEST_VIDEO)
+    video.download(tmp_path)
+
+    temp_files = os.listdir(os.path.join(tmp_path, 'Watch'))
+    has_video = False
+    for afile in temp_files:
+        if afile.endswith('.mp4'):
+            has_video = True
+
+    assert has_video, 'Video file not found'
+
+
+@pytest.mark.skipif(not 'PYTEST_RUN_SLOW' in os.environ, reason="This test can take several minutes to complete.")
+def test_proxy_playlist_download(tmp_path):
+    playlist = YouTubeResource(YOUTUBE_TEST_PLAYLIST)
+    playlist.download(tmp_path)
+
+    temp_files = os.listdir(os.path.join(tmp_path, 'Playlist'))
+    expected = ['zbkizy-Y3qw.jpg', 'oXnzstpBEOg.mp4', 'oXnzstpBEOg.jpg', 'zbkizy-Y3qw.mp4']
+
+    assert  set(temp_files) == set(expected)
diff --git a/tests/media_utils/test_subtitles.py b/tests/media_utils/test_subtitles.py
new file mode 100644
index 00000000..60a71bef
--- /dev/null
+++ b/tests/media_utils/test_subtitles.py
@@ -0,0 +1,122 @@
+import codecs
+import os
+import hashlib
+import tempfile
+from unittest import TestCase
+from ricecooker.utils.subtitles import build_subtitle_converter_from_file
+from ricecooker.utils.subtitles import LANGUAGE_CODE_UNKNOWN
+from ricecooker.utils.subtitles import InvalidSubtitleFormatError
+from ricecooker.utils.subtitles import InvalidSubtitleLanguageError
+from le_utils.constants import languages, file_formats
+
+test_files_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'files', 'subtitles')
+
+
+class SubtitleConverterTest(TestCase):
+    def get_file_hash(self, path):
+        hash = hashlib.md5()
+        with open(path, 'rb') as fobj:
+            for chunk in iter(lambda: fobj.read(2097152), b""):
+                hash.update(chunk)
+
+        return hash.hexdigest()
+
+    def assertFilesEqual(self, expected_file, actual_file):
+        with codecs.open(actual_file, 'rb', encoding='utf-8') as act, codecs.open(expected_file, 'rb', encoding='utf-8') as exp:
+            for actual_str, expected_str in zip(act.readlines(), exp.readlines()):
+                self.assertEqual(actual_str.strip(), expected_str.strip())
+
+    def test_replace_unknown_language(self):
+        expected_language = languages.getlang_by_name('Arabic')
+
+        converter = build_subtitle_converter_from_file(os.path.join(test_files_dir, 'basic.srt'))
+
+        self.assertTrue(converter.has_language(LANGUAGE_CODE_UNKNOWN))
+        converter.replace_unknown_language(expected_language.code)
+
+        self.assertTrue(converter.has_language(expected_language.code))
+        self.assertFalse(converter.has_language(LANGUAGE_CODE_UNKNOWN))
+
+    def test_srt_conversion(self):
+        expected_file = os.path.join(test_files_dir, 'basic.vtt')
+        expected_language = languages.getlang_by_name('Arabic')
+
+        converter = build_subtitle_converter_from_file(os.path.join(test_files_dir, 'basic.srt'))
+        converter.replace_unknown_language(expected_language.code)
+
+        actual_file_d, actual_file_name = tempfile.mkstemp()
+
+        converter.write(actual_file_name, expected_language.code)
+        self.assertFilesEqual(expected_file, actual_file_name)
+
+        os.close(actual_file_d)
+        os.remove(actual_file_name)
+
+    def test_expected_srt_conversion(self):
+        expected_format = file_formats.SRT
+        expected_file = os.path.join(test_files_dir, 'basic.vtt')
+        expected_language = languages.getlang_by_name('Arabic')
+
+        converter = build_subtitle_converter_from_file(
+            os.path.join(test_files_dir, 'basic.srt'), in_format=expected_format)
+        converter.replace_unknown_language(expected_language.code)
+
+        actual_file_d, actual_file_name = tempfile.mkstemp()
+
+        converter.write(actual_file_name, expected_language.code)
+        self.assertFilesEqual(expected_file, actual_file_name)
+
+        os.close(actual_file_d)
+        os.remove(actual_file_name)
+
+
+    def test_not_expected_type(self):
+        expected_format = file_formats.SCC
+        expected_language = languages.getlang_by_name('Arabic')
+
+        converter = build_subtitle_converter_from_file(
+            os.path.join(test_files_dir, 'basic.srt'), in_format=expected_format)
+
+        with self.assertRaises(InvalidSubtitleFormatError):
+            converter.convert(expected_language.code)
+
+    def test_invalid_format(self):
+        expected_language = languages.getlang_by_name('English')
+
+        converter = build_subtitle_converter_from_file(os.path.join(test_files_dir, 'not.txt'))
+
+        with self.assertRaises(InvalidSubtitleFormatError):
+            converter.convert(expected_language.code)
+
+    def test_invalid_format__empty(self):
+        expected_language = languages.getlang_by_name('English')
+
+        converter = build_subtitle_converter_from_file(os.path.join(test_files_dir, 'empty.ttml'))
+
+        with self.assertRaises(InvalidSubtitleFormatError, msg='Caption file is empty'):
+            converter.convert(expected_language.code)
+
+    def test_valid_language(self):
+        expected_file = os.path.join(test_files_dir, 'encapsulated.vtt')
+        expected_language = languages.getlang_by_name('English')
+
+        converter = build_subtitle_converter_from_file(
+            os.path.join(test_files_dir, 'encapsulated.sami'))
+        self.assertTrue(converter.has_language(expected_language.code))
+
+        actual_file_d, actual_file_name = tempfile.mkstemp()
+
+        converter.write(actual_file_name, expected_language.code)
+        self.assertFilesEqual(expected_file, actual_file_name)
+
+        os.close(actual_file_d)
+        os.remove(actual_file_name)
+
+    def test_invalid_language(self):
+        expected_language = languages.getlang_by_name('Spanish')
+
+        converter = build_subtitle_converter_from_file(
+            os.path.join(test_files_dir, 'encapsulated.sami'))
+
+        with self.assertRaises(InvalidSubtitleLanguageError):
+            converter.convert(expected_language.code)
diff --git a/tests/media_utils/test_thumbnails.py b/tests/media_utils/test_thumbnails.py
new file mode 100644
index 00000000..aace30c0
--- /dev/null
+++ b/tests/media_utils/test_thumbnails.py
@@ -0,0 +1,274 @@
+import os
+import PIL
+import pytest
+
+from ricecooker.utils import images
+from ricecooker.utils import videos
+
+from .test_videos import low_res_video, high_res_video, bad_video, TempFile
+
+
+tests_dir = os.path.dirname(os.path.abspath(__file__))
+files_dir = os.path.join(tests_dir, 'files')
+outputs_dir = os.path.join(files_dir, 'expected_output')
+
+# these settings are chosen to match our current use case in Studio
+studio_cmap_options = {'name': 'BuPu', 'vmin': 0.3, 'vmax': 0.7, 'color': 'black'}
+
+
+SHOW_THUMBS = False     # set to True to show outputs when running tests locally
+
+
+
+
+# TESTS
+################################################################################
+
+class BaseThumbnailGeneratorTestCase(object):
+
+    def check_is_png_file(self, output_file):
+        PNG_MAGIC_NUMBER = b'\x89P'
+        with open(output_file, "rb") as f:
+            f.seek(0)
+            assert f.read(2) == PNG_MAGIC_NUMBER
+            f.close()
+
+    def check_thumbnail_generated(self, output_file):
+        """
+        Checks that a thumbnail file at output_file exists and not too large.
+        """
+        assert os.path.exists(output_file)
+        im = PIL.Image.open(output_file)
+        width, height = im.size
+        if SHOW_THUMBS: im.show()
+        assert width < 1000, 'thumbnail generated is too large (w >= 1000)'
+        assert height < 1000, 'thumbnail generated is too tall (h >= 1000)'
+        return im
+
+    def check_16_9_format(self, output_file):
+        """
+        Checks that a thumbnail file at output_file exists and not too large,
+        and roughly in 16:9 aspect ratio.
+        """
+        assert os.path.exists(output_file)
+        im = PIL.Image.open(output_file)
+        width, height = im.size
+        assert float(width)/float(height) == 16.0/9.0
+        if SHOW_THUMBS: im.show()
+        return im
+
+class Test_pdf_thumbnail_generation(BaseThumbnailGeneratorTestCase):
+
+    def test_generates_thumbnail(self, tmpdir):
+        input_file = os.path.join(files_dir, "generate_thumbnail", "sample.pdf")
+        assert os.path.exists(input_file)
+        output_file = tmpdir.join("pdf.png").strpath
+        images.create_image_from_pdf_page(input_file, output_file)
+        self.check_thumbnail_generated(output_file)
+
+    def test_generates_16_9_thumbnail(self, tmpdir):
+        input_file = os.path.join(files_dir, "generate_thumbnail", "sample.pdf")
+        assert os.path.exists(input_file)
+        output_file = tmpdir.join("pdf_16_9.png").strpath
+        images.create_image_from_pdf_page(input_file, output_file, crop='smart')
+        self.check_16_9_format(output_file)
+
+    def test_raises_for_missing_file(self, tmpdir):
+        input_file = os.path.join(files_dir, 'file_that_does_not_exist.pdf')
+        assert not os.path.exists(input_file)
+        output_file = tmpdir.join('thumbnail.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_image_from_pdf_page(input_file, output_file)
+
+    def test_raises_for_invalid_pdf(self, tmpdir, bad_pdf_file):
+        input_file = bad_pdf_file.name
+        output_file = tmpdir.join('thumbnail.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_image_from_pdf_page(input_file, output_file)
+
+
+class Test_html_zip_thumbnail_generation(BaseThumbnailGeneratorTestCase):
+
+    def test_generates_16_9_thumbnail(self, tmpdir):
+        """
+        The test fixtue `sample.zip` contains three images, one tall, one wide,
+        and one roughly square. The "choose largest area" logic shoudl select the
+        blue one to use as the thumbnail.
+        """
+        input_file = os.path.join(files_dir, 'generate_thumbnail', 'sample.zip')
+        assert os.path.exists(input_file)
+        output_file = tmpdir.join('zipfile.png').strpath
+        images.create_image_from_zip(input_file, output_file)
+        im = self.check_16_9_format(output_file)
+        # check is blue image
+        r, g, b = im.getpixel((1, 1))
+        assert b>g and b>r, (r,g,b)
+
+    def test_raises_for_missing_file(self, tmpdir):
+        input_file = os.path.join(files_dir, 'file_that_does_not_exist.zip')
+        assert not os.path.exists(input_file)
+        output_file = tmpdir.join('thumbnail.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_image_from_zip(input_file, output_file)
+
+    def test_raises_for_invalid_zip(self, tmpdir, bad_zip_file):
+        input_file = bad_zip_file.name
+        output_file = tmpdir.join('thumbnail.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_image_from_zip(input_file, output_file)
+
+
+
+class Test_tiled_thumbnail_generation(BaseThumbnailGeneratorTestCase):
+
+    def test_generates_brazil_thumbnail(self, tmpdir):
+        input_file = os.path.join(files_dir, 'thumbnails', 'BRAlogo1.png')
+        assert os.path.exists(input_file)
+        input_files = [input_file, input_file, input_file, input_file]
+        output_file = tmpdir.join('tiled.png').strpath
+        images.create_tiled_image(input_files, output_file)
+        self.check_16_9_format(output_file)
+
+    def test_generates_kolibris_thumbnail(self, tmpdir):
+        filenames = ['BRAlogo1.png', 'toosquare.png', 'tootall.png', 'toowide.png']
+        input_files = []
+        for filename in filenames:
+            input_file = os.path.join(files_dir, 'thumbnails', filename)
+            assert os.path.exists(input_file)
+            input_files.append(input_file)
+        output_file = tmpdir.join('tiled.png').strpath
+        images.create_tiled_image(input_files, output_file)
+        self.check_16_9_format(output_file)
+
+    def test_raises_for_missing_file(self, tmpdir):
+        input_file = os.path.join(files_dir, 'file_that_does_not_exist.png')
+        assert not os.path.exists(input_file)
+        input_files = [input_file, input_file, input_file, input_file]
+        output_file = tmpdir.join('tiled.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_tiled_image(input_files, output_file)
+
+    def test_raises_for_wrong_number_of_files(self, tmpdir):
+        input_file = os.path.join(files_dir, 'file_that_does_not_exist.png')
+        assert not os.path.exists(input_file)
+        input_files = [input_file, input_file, input_file, input_file]
+        output_file = tmpdir.join('tiled.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_tiled_image(input_files, output_file)
+
+    def test_raises_for_invalid_png(self, tmpdir, bad_png_file):
+        input_file = bad_png_file.name
+        input_files = [input_file, input_file, input_file, input_file]
+        output_file = tmpdir.join('tiled.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_tiled_image(input_files, output_file)
+
+
+class Test_epub_thumbnail_generation(BaseThumbnailGeneratorTestCase):
+
+    def test_generates_thumbnail(self, tmpdir):
+        input_file = os.path.join(files_dir, 'generate_thumbnail', 'sample.epub')
+        assert os.path.exists(input_file)
+        output_file = tmpdir.join('epub.png').strpath
+        images.create_image_from_epub(input_file, output_file)
+        self.check_thumbnail_generated(output_file)
+
+    def test_generates_16_9_thumbnail(self, tmpdir):
+        input_file = os.path.join(files_dir, 'generate_thumbnail', 'sample.epub')
+        assert os.path.exists(input_file)
+        output_file = tmpdir.join('epub_16_9.png').strpath
+        images.create_image_from_epub(input_file, output_file, crop='smart')
+        self.check_16_9_format(output_file)
+
+    def test_generates_16_9_thumbnail_from_top(self, tmpdir):
+        input_file = os.path.join(files_dir, 'generate_thumbnail', 'sample.epub')
+        assert os.path.exists(input_file)
+        output_file = tmpdir.join('epub_16_9_top.png').strpath
+        images.create_image_from_epub(input_file, output_file, crop=",0")
+        self.check_16_9_format(output_file)
+
+    def test_raises_for_missing_file(self, tmpdir):
+        input_file = os.path.join(files_dir, 'file_that_does_not_exist.epub')
+        assert not os.path.exists(input_file)
+        output_file = tmpdir.join('thumbnail.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_image_from_epub(input_file, output_file)
+
+    def test_raises_for_invalid_epub(self, tmpdir, bad_epub_file):
+        input_file = bad_epub_file.name
+        output_file = tmpdir.join('thumbnail.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            images.create_image_from_epub(input_file, output_file)
+
+
+
+class Test_video_thumbnail_generation(BaseThumbnailGeneratorTestCase):
+
+    def test_generates_16_9_thumbnail_from_low_res(self, tmpdir, low_res_video):
+        input_file = low_res_video.name
+        output_file = tmpdir.join('low_res_video_thumbnail.png').strpath
+        videos.extract_thumbnail_from_video(input_file, output_file, overwrite=True)
+        self.check_16_9_format(output_file)
+        self.check_is_png_file(output_file)
+
+    def test_generates_16_9_thumbnail_from_high_res(self, tmpdir, high_res_video):
+        input_file = high_res_video.name
+        output_file = tmpdir.join('high_res_video_thumbnail.png').strpath
+        videos.extract_thumbnail_from_video(input_file, output_file, overwrite=True)
+        self.check_16_9_format(output_file)
+        self.check_is_png_file(output_file)
+
+    def test_raises_for_missing_file(self, tmpdir):
+        input_file = os.path.join(files_dir, 'file_that_does_not_exist.mp4')
+        assert not os.path.exists(input_file)
+        output_file = tmpdir.join('thumbnail.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            videos.extract_thumbnail_from_video(input_file, output_file, overwrite=True)
+
+    def test_bad_video_raises(self, tmpdir, bad_video):
+        input_file = bad_video.name
+        output_file = tmpdir.join('bad_video_thumbnail.png').strpath
+        with pytest.raises(images.ThumbnailGenerationError):
+            videos.extract_thumbnail_from_video(input_file, output_file, overwrite=True)
+
+
+
+
+# FIXTURES
+################################################################################
+
+@pytest.fixture
+def bad_audio_file():
+    with TempFile(suffix='.mp3') as f:
+        f.write(b'no mp3 here; ffmpeg should error out.')
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+@pytest.fixture
+def bad_pdf_file():
+    with TempFile(suffix='.pdf') as f:
+        f.write(b'no pdf here; thumbnail extraction should error out.')
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+@pytest.fixture
+def bad_zip_file():
+    with TempFile(suffix='.zip') as f:
+        f.write(b'no zip here; thumbnail extraction should error out.')
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+@pytest.fixture
+def bad_epub_file():
+    with TempFile(suffix='.epub') as f:
+        f.write(b'no epub here; thumbnail extraction should error out.')
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+@pytest.fixture
+def bad_png_file():
+    with TempFile(suffix='.png') as f:
+        f.write(b'no image here; tiled thumbnail processing should error out.')
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
diff --git a/tests/media_utils/test_videos.py b/tests/media_utils/test_videos.py
new file mode 100644
index 00000000..2c74dde7
--- /dev/null
+++ b/tests/media_utils/test_videos.py
@@ -0,0 +1,254 @@
+from __future__ import print_function
+import atexit
+import os
+import PIL
+import pytest
+import re
+import requests
+import requests_cache
+import subprocess
+import sys
+import tempfile
+
+from le_utils.constants import format_presets
+from ricecooker.utils import videos
+
+
+# cache, so we don't keep requesting the full videos
+if sys.version_info[0] == 3:
+    requests_cache.install_cache("video_cache_py3")
+else:
+    requests_cache.install_cache("video_cache")
+
+
+# FIXTURES
+################################################################################
+
+@pytest.fixture
+def low_res_video():
+    with TempFile(suffix='.mp4') as f:
+        resp = requests.get(
+            "https://archive.org/download/vd_is_for_everybody/vd_is_for_everybody_512kb.mp4",
+            stream=True,
+        )
+        for chunk in resp.iter_content(chunk_size=1048576):
+            f.write(chunk)
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+
+@pytest.fixture
+def high_res_video():
+    with TempFile(suffix='.mp4') as f:
+        resp = requests.get(
+            "https://ia800201.us.archive.org/7/items/"
+            "UnderConstructionFREEVideoBackgroundLoopHD1080p/"
+            "UnderConstruction%20-%20FREE%20Video%20Background%20Loop%20HD%201080p.mp4",
+            stream=True
+        )
+        for chunk in resp.iter_content(chunk_size=1048576):
+            f.write(chunk)
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+
+@pytest.fixture
+def low_res_video_webm():
+    with TempFile(suffix='.webm') as f:
+        resp = requests.get(
+            "https://mirrors.creativecommons.org/movingimages/"
+            "webm/CreativeCommonsPlusCommercial_240p.webm",
+            stream=True
+        )
+        for chunk in resp.iter_content(chunk_size=1048576):
+            f.write(chunk)
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+
+@pytest.fixture
+def high_res_video_webm():
+    with TempFile(suffix='.webm') as f:
+        resp = requests.get(
+            "https://mirrors.creativecommons.org/movingimages/"
+            "webm/CreativeCommonsPlusCommercial_720p.webm",
+            stream=True
+        )
+        for chunk in resp.iter_content(chunk_size=1048576):
+            f.write(chunk)
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+
+@pytest.fixture
+def low_res_ogv_video():
+    with TempFile(suffix='.ogv') as f:
+        resp = requests.get(
+            "https://archive.org/download/"
+            "UnderConstructionFREEVideoBackgroundLoopHD1080p/"
+            "UnderConstruction%20-%20FREE%20Video%20Background%20Loop%20HD%201080p.ogv",
+            stream=True
+        )
+        for chunk in resp.iter_content(chunk_size=1048576):
+            f.write(chunk)
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+
+@pytest.fixture
+def high_res_mov_video():
+    with TempFile(suffix='.mov') as f:
+        resp = requests.get(
+            "https://ia800201.us.archive.org/7/items/"
+            "UnderConstructionFREEVideoBackgroundLoopHD1080p/"
+            "cold%20night.mov",
+            stream=True
+        )
+        for chunk in resp.iter_content(chunk_size=1048576):
+            f.write(chunk)
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+
+@pytest.fixture
+def bad_video():
+    with TempFile(suffix='.mp4') as f:
+        f.write(b'novideohere. ffmpeg soshould error')
+        f.flush()
+    return f            # returns a temporary file with a closed file descriptor
+
+
+# TESTS
+################################################################################
+
+class Test_check_video_resolution:
+
+    def test_returns_a_format_preset(self, low_res_video):
+        preset = videos.guess_video_preset_by_resolution(low_res_video.name)
+        assert preset in [format_presets.VIDEO_HIGH_RES,
+                          format_presets.VIDEO_LOW_RES,
+                          format_presets.VIDEO_VECTOR]
+
+    def test_detects_low_res_videos(self, low_res_video):
+        preset = videos.guess_video_preset_by_resolution(low_res_video.name)
+        assert preset == format_presets.VIDEO_LOW_RES
+
+    def test_detects_high_res_videos(self, high_res_video):
+        preset = videos.guess_video_preset_by_resolution(high_res_video.name)
+        assert preset == format_presets.VIDEO_HIGH_RES
+
+    def test_detects_low_res_videos_webm(self, low_res_video_webm):
+        preset = videos.guess_video_preset_by_resolution(
+            low_res_video_webm.name)
+        assert preset == format_presets.VIDEO_LOW_RES
+
+    def test_detects_high_res_videos_webm(self, high_res_video_webm):
+        preset = videos.guess_video_preset_by_resolution(
+            high_res_video_webm.name)
+        assert preset == format_presets.VIDEO_HIGH_RES
+
+
+def get_resolution(videopath):
+    """Helper function to get resolution of video at videopath."""
+    result = subprocess.check_output(['ffprobe', '-v', 'error',
+                                      '-print_format', 'json', '-show_entries',
+                                      'stream=width,height', '-of',
+                                      'default=noprint_wrappers=1',
+                                      str(videopath)])
+    pattern = re.compile('width=([0-9]*)[^height]+height=([0-9]*)')
+    m = pattern.search(str(result))
+    width, height = int(m.group(1)), int(m.group(2))
+    return width, height
+
+
+class Test_compress_video:
+
+    def test_compression_works(self, high_res_video):
+        with TempFile(suffix=".mp4") as vout:
+            videos.compress_video(high_res_video.name,
+                                  vout.name, overwrite=True)
+            width, height = get_resolution(vout.name)
+            assert height == 480, 'should compress to 480 v resolution by default'
+
+    def test_compression_max_width(self, high_res_video):
+        with TempFile(suffix=".mp4") as vout:
+            videos.compress_video(high_res_video.name,
+                                  vout.name, overwrite=True, max_width=120)
+            width, height = get_resolution(vout.name)
+            assert width == 120, 'should be 120 h resolution since max_width set'
+
+    def test_compression_max_width_odd(self, high_res_video):
+        """
+        regression test for: https://github.com/learningequality/pressurecooker/issues/11
+        """
+        with TempFile(suffix=".mp4") as vout:
+            videos.compress_video(high_res_video.name,
+                                  vout.name, overwrite=True, max_width=121)
+            width, height = get_resolution(vout.name)
+            assert width == 120, 'should round down to 120 h resolution when max_width=121 set'
+
+    def test_compression_max_height(self, high_res_video):
+        with TempFile(suffix=".mp4") as vout:
+            videos.compress_video(high_res_video.name,
+                                  vout.name, overwrite=True, max_height=140)
+            width, height = get_resolution(vout.name)
+            assert height == 140, 'should be 140 v resolution since max_height set'
+
+    def test_raises_for_bad_file(self, bad_video):
+        with TempFile(suffix=".mp4") as vout:
+            with pytest.raises(videos.VideoCompressionError):
+                videos.compress_video(
+                    bad_video.name, vout.name, overwrite=True)
+
+
+class Test_convert_video:
+
+    def test_convert_mov_works(self, high_res_mov_video):
+        with TempFile(suffix=".mp4") as vout:
+            videos.compress_video(high_res_mov_video.name,
+                                  vout.name, overwrite=True)
+            width, height = get_resolution(vout.name)
+            assert height == 480, 'should convert .ogv to .mp4 and set 480 v res'
+
+    def test_convert_and_resize_ogv_works(self, low_res_ogv_video):
+        with TempFile(suffix=".mp4") as vout:
+            videos.compress_video(low_res_ogv_video.name,
+                                  vout.name, overwrite=True, max_height=200)
+            width, height = get_resolution(vout.name)
+            assert height == 200, 'should convert .ogv to .mp4 and set 200 v res'
+
+
+# Helper class for cross-platform temporary files
+################################################################################
+
+def remove_temp_file(*args, **kwargs):
+    filename = args[0]
+    try:
+        os.remove(filename)
+    except FileNotFoundError:
+        pass
+    assert not os.path.exists(filename)
+
+
+class TempFile(object):
+    """
+    tempfile.NamedTemporaryFile deletes the file as soon as the filehandle is closed.
+    This is OK on unix but on Windows the file can't be used by other commands
+    (i.e. ffmpeg) unti the file is closed.
+    Temporary files are instead deleted when we quit.
+    """
+
+    def __init__(self, *args, **kwargs):
+        # all parameters will be passed to NamedTemporaryFile
+        self.args = args
+        self.kwargs = kwargs
+
+    def __enter__(self):
+        # create a temporary file as per usual, but set it up to be deleted once we're done
+        self.f = tempfile.NamedTemporaryFile(
+            *self.args, delete=False, **self.kwargs)
+        atexit.register(remove_temp_file, self.f.name)
+        return self.f
+
+    def __exit__(self, _type, value, traceback):
+        self.f.close()
diff --git a/tests/media_utils/test_web.py b/tests/media_utils/test_web.py
new file mode 100644
index 00000000..1f03438d
--- /dev/null
+++ b/tests/media_utils/test_web.py
@@ -0,0 +1,75 @@
+import os
+import tempfile
+
+from ricecooker.utils import web
+
+test_dir = os.path.dirname(__file__)
+
+
+def test_get_links():
+    filename = os.path.abspath(os.path.join(test_dir, "files", "page_with_links.html"))
+    parser = web.HTMLParser(filename)
+    links = parser.get_links()
+
+    expected_links = [
+        'assets/css/empty.css',
+        'assets/css/empty2.css',
+        'assets/js/empty.js',
+        'assets/images/4933759886_098e9acf93_m.jpg',
+        'the_spanish_inquisition.html',
+        'http://www.learningequality.org',
+        'Wilhelm_Scream.mp3'
+    ]
+
+    # make sure the link order is the same to do an equality test
+    links.sort()
+    expected_links.sort()
+
+    assert links == expected_links
+
+
+def test_get_local_files():
+    filename = os.path.abspath(os.path.join(test_dir, "files", "page_with_links.html"))
+    parser = web.HTMLParser(filename)
+    links = parser.get_local_files()
+
+    expected_links = [
+        'assets/css/empty.css',
+        'assets/css/empty2.css',
+        'assets/js/empty.js',
+        'assets/images/4933759886_098e9acf93_m.jpg',
+        'the_spanish_inquisition.html',
+        'Wilhelm_Scream.mp3'
+    ]
+
+    # make sure the link order is the same to do an equality test
+    links.sort()
+    expected_links.sort()
+
+    assert links == expected_links
+
+
+def test_replace_links():
+    filename = os.path.abspath(os.path.join(test_dir, "files", "page_with_links.html"))
+    parser = web.HTMLParser(filename)
+
+    original_links = [
+        'assets/css/empty.css',
+        'assets/css/empty2.css',
+        'assets/js/empty.js',
+        'assets/images/4933759886_098e9acf93_m.jpg',
+        'the_spanish_inquisition.html',
+        'Wilhelm_Scream.mp3'
+    ]
+
+    replacement_links = {}
+    for link in original_links:
+        replacement_links[link] = '/zipcontent/012343545454645454/{}'.format(link)
+
+    new_html = parser.replace_links(replacement_links)
+
+    new_parser = web.HTMLParser(html=new_html)
+    links = new_parser.get_links()
+
+    for link in links:
+        assert link == replacement_links[link]
diff --git a/tests/media_utils/test_youtube.py b/tests/media_utils/test_youtube.py
new file mode 100644
index 00000000..0f3fe0fe
--- /dev/null
+++ b/tests/media_utils/test_youtube.py
@@ -0,0 +1,219 @@
+import os
+import shutil
+import tempfile
+
+import pytest
+
+from ricecooker.utils import utils
+from ricecooker.utils import youtube
+from le_utils.constants import file_formats
+
+trees = {}
+yt_resources = {}
+USE_PROXY_FOR_TESTS = False
+
+cc_playlist = 'https://www.youtube.com/playlist?list=PL7m903CwFUgntbjkVMwts89fZq0INCtVS'
+non_cc_playlist = 'https://www.youtube.com/playlist?list=PLBO8M-O_dTPE51ymDUgilf8DclGAEg9_A'
+subtitles_video = 'https://www.youtube.com/watch?v=6uXAbJQoZlE'
+subtitles_zu_video = 'https://www.youtube.com/watch?v=FN12ty5ztAs'
+
+
+def get_yt_resource(url, **kwargs):
+    global yt_resources
+    if not url in yt_resources:
+        if 'useproxy' not in kwargs:
+            if USE_PROXY_FOR_TESTS:
+                kwargs['useproxy'] = True
+            else:
+                kwargs['useproxy'] = False
+        yt_resources[url] = youtube.YouTubeResource(url, **kwargs)
+
+    return yt_resources[url]
+
+
+def test_get_youtube_info():
+    yt_resource = get_yt_resource(non_cc_playlist)
+    tree = yt_resource.get_resource_info()
+    assert tree['id']
+    assert tree['kind']
+    assert tree['title']
+    assert len(tree['children']) == 4
+
+    for video in tree['children']:
+        assert video['id']
+        assert video['kind']
+        assert video['title']
+
+
+def test_warnings_no_license():
+    yt_resource = get_yt_resource(non_cc_playlist)
+    issues, output_info = yt_resource.check_for_content_issues()
+
+    assert len(issues) == 4
+    for issue in issues:
+        assert 'no_license_specified' in issue['warnings']
+
+
+def test_cc_no_warnings():
+    yt_resource = get_yt_resource(cc_playlist)
+    issues, output_info = yt_resource.check_for_content_issues()
+
+    # there is one video in this playlist that is not cc-licensed
+    assert len(issues) == 1
+    for issue in issues:
+        assert 'no_license_specified' in issue['warnings']
+
+
+@pytest.mark.skipif(True, reason="Skipping download tests.")
+def test_download_youtube_video():
+    download_dir = tempfile.mkdtemp()
+
+    try:
+        yt_resource = get_yt_resource(subtitles_video)
+        info = yt_resource.download(base_path=download_dir)
+        assert info
+        if info:
+            assert 'filename' in info
+            assert os.path.exists(info['filename']), 'Filename {} does not exist'.format(info['filename'])
+
+    finally:
+        shutil.rmtree(download_dir)
+
+
+@pytest.mark.skipif(True, reason="Skipping download tests.")
+def test_download_youtube_playlist():
+    download_dir = tempfile.mkdtemp()
+
+    try:
+        yt_resource = get_yt_resource(cc_playlist)
+        info = yt_resource.download(base_path=download_dir)
+        assert info is not None
+        if info:
+            assert not 'filename' in info
+            assert 'children' in info
+            for child in info['children']:
+                assert 'filename' in child
+                assert os.path.exists(child['filename']), 'Filename {} does not exist'.format(child['filename'])
+
+    finally:
+        shutil.rmtree(download_dir)
+
+
+def test_get_subtitles():
+    yt_resource = get_yt_resource(subtitles_video)
+    info = yt_resource.get_resource_subtitles()
+    assert len(info['subtitles']) == 4  # brittle; can change if subs get added
+    assert 'ru' in info['subtitles']
+    assert 'en' in info['subtitles']
+    assert 'zh-CN' in info['subtitles']
+    assert 'es' in info['subtitles']
+
+def test_non_youtube_url_error():
+    url = 'https://vimeo.com/238190750'
+    with pytest.raises(utils.VideoURLFormatError):
+        youtube.YouTubeResource(url)
+
+
+def test_subtitles_lang_helpers_compatible():
+    """
+    Usage examples functions `is_youtube_subtitle_file_supported_language` and
+    `_get_language_with_alpha2_fallback` that deal with language codes.
+    """
+    yt_resource = get_yt_resource(subtitles_zu_video)
+    info = yt_resource.get_resource_subtitles()
+    all_subtitles = info['subtitles']
+
+    # 1. filter out non-vtt subs
+    vtt_subtitles = {}
+    for youtube_language, subs in all_subtitles.items():
+        vtt_subtitles[youtube_language] = [s for s in subs if s['ext'] == 'vtt']
+
+    for youtube_language, sub_dict in vtt_subtitles.items():
+        # 2. check compatibility with le-utils language codes (a.k.a. internal representation)
+        verdict = youtube.is_youtube_subtitle_file_supported_language(youtube_language)
+        assert verdict == True, 'Wrongly marked youtube_language as incompatible'
+        # 3. TODO: figure out what to do for incompatible langs
+
+        # 4. map youtube_language to le-utils language code (a.k.a. internal representation)
+        language_obj = youtube.get_language_with_alpha2_fallback(youtube_language)
+        assert language_obj is not None, 'Failed to find matchin language code in le-utils'
+        if youtube_language == 'zu':
+            assert language_obj.code == 'zul', 'Matched to wrong language code in le-utils'
+
+
+def test_subtitles_lang_helpers_incompatible():
+    """
+    Ensure `is_youtube_subtitle_file_supported_language` rejects unknown language codes.
+    """
+    verdict1 = youtube.is_youtube_subtitle_file_supported_language('patapata')
+    assert verdict1 == False, 'Failed to reject incompatible youtube_language'
+    verdict2 = youtube.is_youtube_subtitle_file_supported_language('zzz')
+    assert verdict2 == False, 'Failed to reject incompatible youtube_language'
+
+
+@pytest.mark.skipif(not 'PYTEST_RUN_SLOW' in os.environ, reason="This test can take several minutes to complete.")
+@pytest.mark.parametrize("useproxy", [True, False])
+@pytest.mark.parametrize("useproxy_for_download", [False])
+def test_download_from_web_video_file(tmp_path, useproxy, useproxy_for_download):
+    """
+    Test for functionality required by download_from_web for WebVideoFile processing.
+    """
+    for youtube_url in [subtitles_video, subtitles_zu_video]:
+        download_ext = ".{ext}".format(ext=file_formats.MP4)
+        destination_path = os.path.join(tmp_path, youtube_url[-11:] + download_ext)
+
+        # STEP 1: get_resource_info via proxy
+        settings = {}
+        maxheight = 480
+        settings['format'] = "bestvideo[height<={maxheight}][ext=mp4]+bestaudio[ext=m4a]/best[height<={maxheight}][ext=mp4]".format(maxheight=maxheight)
+        settings['outtmpl'] = destination_path
+        yt_resource = youtube.YouTubeResource(youtube_url, useproxy=useproxy, options=settings)
+        video_node1 = yt_resource.get_resource_info()
+        assert video_node1, 'no data returned'
+
+        # STEP 2: download
+        # overwrite default download behaviour by setting custom options
+        download_settings = {}
+        download_settings["writethumbnail"] = False
+        download_settings["outtmpl"] = destination_path
+        video_node2 = yt_resource.download(options=download_settings, useproxy=useproxy_for_download)
+        assert os.path.exists(destination_path), 'Missing video file'
+
+
+@pytest.mark.skipif(not 'PYTEST_RUN_SLOW' in os.environ, reason="This test can take several minutes to complete.")
+@pytest.mark.parametrize("useproxy", [True, False])
+@pytest.mark.parametrize("useproxy_for_download", [False])
+def test_download_from_web_subtitle_file(tmp_path, useproxy, useproxy_for_download):
+    """
+    Use YouTubeResource the same way YouTubeSubtitleFile when proxy is enabled.
+    """
+    for youtube_url, lang in [(subtitles_video,'ru'), (subtitles_zu_video, 'zu')]:
+        destination_path_noext = os.path.join(tmp_path, youtube_url[-11:])
+        download_ext = ".{lang}.{ext}".format(lang=lang, ext=file_formats.VTT)
+        destination_path = destination_path_noext + download_ext
+
+        # STEP 1: get_resource_info
+        settings = {
+            'outtmpl': destination_path_noext, # note no ext -- YoutubeDL will auto append it,
+            'skip_download': True,
+            'writesubtitles': True,
+            'subtitleslangs': [lang],
+            'subtitlesformat': "best[ext={}]".format(file_formats.VTT),
+            'quiet': True,
+            'verbose': True,
+            'no_warnings': True
+        }
+        web_url = youtube_url
+        yt_resource = youtube.YouTubeResource(web_url, useproxy=useproxy, options=settings)
+        video_node = yt_resource.get_resource_info()
+        # checks for STEP 1
+        assert video_node['subtitles'], 'missing subtitles key'
+
+        # STEP 2: download
+        # overwrite default download behaviour by setting custom options
+        download_settings = {}
+        download_settings["writethumbnail"] = False
+        download_settings["outtmpl"] = destination_path_noext
+        yt_resource.download(options=download_settings, useproxy=useproxy_for_download)
+        # checks for STEP 2
+        assert os.path.exists(destination_path), 'Missing subtitles file'
diff --git a/tests/test_thumbnails.py b/tests/test_thumbnails.py
index 1c854b94..eea45daf 100644
--- a/tests/test_thumbnails.py
+++ b/tests/test_thumbnails.py
@@ -4,8 +4,8 @@
 
 from le_utils.constants import licenses
 from ricecooker import config
-from ricecooker.classes.files import AudioFile, DocumentFile, EPubFile, HTMLZipFile, ThumbnailFile, TiledThumbnailFile, SlideImageFile, SubtitleFile, VideoFile
-from ricecooker.classes.nodes import AudioNode, ChannelNode, DocumentNode, ExerciseNode, HTML5AppNode, SlideshowNode, TopicNode, VideoNode
+from ricecooker.classes.files import AudioFile, DocumentFile, HTMLZipFile, ThumbnailFile, TiledThumbnailFile, VideoFile
+from ricecooker.classes.nodes import AudioNode, DocumentNode, HTML5AppNode, TopicNode, VideoNode
 
 from test_tree import thumbnail_path, thumbnail_path_jpg
 from test_videos import low_res_video
@@ -196,14 +196,6 @@ def test_generate_thumbnail_from_video(self, video_file):
         assert len(filenames) == 2, 'expected two filenames'
         self.check_has_thumbnail(node)
 
-    def test_generate_thumbnail_from_audio(self, audio_file):
-        node = AudioNode('audio-src-id', "Audio", licenses.PUBLIC_DOMAIN, thumbnail=None)
-        node.add_file(audio_file)
-        config.THUMBNAILS = True
-        filenames = node.process_files()
-        assert len(filenames) == 2, 'expected two filenames'
-        self.check_has_thumbnail(node)
-
     def test_generate_tiled_thumbnail(self, document, html, video, audio):
         topic = TopicNode('test-topic', 'Topic')
         topic.add_child(document)
diff --git a/tests/test_videos.py b/tests/test_videos.py
index 3d6188e8..71bd30c8 100644
--- a/tests/test_videos.py
+++ b/tests/test_videos.py
@@ -8,10 +8,9 @@
 
 from le_utils.constants import format_presets
 from le_utils.constants import licenses
-from pressurecooker import videos
 from ricecooker import config
 from ricecooker.classes.files import FILECACHE
-from ricecooker.classes.files import SubtitleFile, VideoFile, YouTubeVideoFile
+from ricecooker.classes.files import SubtitleFile, VideoFile
 from ricecooker.classes.nodes import VideoNode
 
 from conftest import download_fixture_file