2
\$\begingroup\$

EDIT: This code is now public on GitHub with more focused purpose in case anyone is interested. Thanks for the help!

This is only used on Mac machines. I know that there is a Python implementation of youtube-dl and several for ffmpeg, however I chose to use subprocess so that I can see the real-time stdout/stderr of them while videos are downloading/encoding.

The goal

Using youtube-dl and ffmpeg

Easy to use (for non-programmers) interactive command line interface that can:

  1. Download a YouTube video.
  2. Optionally burn subtitles into the video.
    • Either subtitles that the uploader entered or YouTube auto-generated subtitles (if they exist).

This is not meant to be an all-encompassing program for youtube-dl and ffmpeg and their many options, it is simply intended to perform the above 2 tasks easily.

My main concerns

  1. Ensuring that if this program fails on a particular video (or for any reason), it fails elegantly such that the lamen user doesn't have to do anything magical to get back on track and try another video.

  2. In the long run I would like to build a GUI for this but I have no experience doing that yet. I'm hoping that I'm programming this in a way that is relatively easy to turn into a program with a GUI later, so any advice in that area will be greatly appreciated.

The Code

#!/usr/bin/python

import shutil
import os
import urllib2
import subprocess


# Set paths
HOME = os.path.expanduser("~")
DOWNLOAD_LOCATION = os.path.join(HOME, 'Desktop/YT_Downloads/')
DOWNLOADING = os.path.join(HOME, DOWNLOAD_LOCATION, '.downloading/')
ENCODING = os.path.join(HOME, DOWNLOAD_LOCATION, '.encoding/')

SUPPORTED_SUBS = ['.srt', '.sbv', '.sub', '.mpsub', '.lrc', '.cap', '.smi',
                  '.sami', '.rt', '.vtt', '.ttml', '.dfxp', '.scc', '.stl',
                  '.tds', '.cin', '.asc']


# Clear terminal window
def clear():
    p = subprocess.Popen(['clear'])
    p.communicate()


def intro_message():
    print '-----------------------------------------------------------------'
    print 'Follow instructions to download a YouTube video:'
    print 'Files will be downloaded to: {path}'.format(path=DOWNLOAD_LOCATION)
    print '-----------------------------------------------------------------\n'


# Create necessary directories
def make_dirs():
    for folder in [DOWNLOAD_LOCATION, DOWNLOADING, ENCODING]:
        if not os.path.exists(folder):
            os.mkdir(folder)


# Prompt user to enter YouTube link
def get_url():
    url = raw_input('Enter YouTube link: ')
    if not check_url(url):
        print 'Something went wrong, please ensure you entered a valid URL.'
        get_url()
    return url


# Check if URL is valid
def check_url(url):
    try:
        urllib2.urlopen(url)
        return True
    except:
        return False


# Ask user if they would like to burn subtitles into video after download
def get_captions():
    user_input = raw_input('Burn subtitles into video? (yes/no): ')
    if not user_input[0].lower() == 'y':
        return False
    return True

# If user wants captions, ask if they want them auto-generated by YouTube
# This is not available for all videos
def get_auto_captions():
    user_input = raw_input('Auto-generated subtitles? (yes/no): ')
    if not user_input[0].lower() == 'y':
        return False
    return True


# Return tuple of filepaths to use for encoding/burning/moving
def get_files():
    vid = subs = None
    files = os.listdir(DOWNLOADING)
    for f in files:
        if f.startswith('.'):
            continue
        if os.path.splitext(f)[1] in SUPPORTED_SUBS:
            subs = f
        if os.path.splitext(f)[1] == '.mp4':
            vid = f
    return (os.path.join(DOWNLOADING, vid) if vid else None,
            os.path.join(DOWNLOADING, subs) if subs else None)


# Burn in subs with FFMPEG
def burn_captions(VIDPATH, SUBSPATH):
    filename = os.path.basename(VIDPATH)
    # Prepend filename with "CC_" to indicate that it has captions
    OUTPATH = os.path.join(ENCODING, 'CC_' + filename)
    p = subprocess.Popen(['ffmpeg', '-i', VIDPATH, '-vf',
                          'subtitles={0}'.format(SUBSPATH),
                          OUTPATH])
    # Wait for download to finish
    p.communicate()
    return [OUTPATH]


def download(url, captions, auto_captions):
    if not captions:
        dl_with_no_cc(url)
    elif auto_captions:
        dl_with_auto_cc(url)
    elif captions:
        dl_with_cc(url)


# Download video with no captions
def dl_with_no_cc(url):
    p = subprocess.Popen(('youtube-dl '
                          '-f bestvideo[ext=mp4]+bestaudio[ext=m4a]/best '
                          '--merge-output-format mp4 '
                          '--restrict-filenames '
                          '--prefer-ffmpeg '
                          '-o "{path}%(title)s.%(ext)s" '
                          '{url}'.format(path=DOWNLOADING, url=url)),
                          shell=True)
    # Wait for download to finish
    p.communicate()


# Download video + closed captions
def dl_with_cc(url):
    p = subprocess.Popen(('youtube-dl '
                          '-f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best" '
                          '--merge-output-format mp4 '
                          '--write-srt '
                          '--sub-lang en '
                          '--restrict-filenames '
                          '--prefer-ffmpeg '
                          '-o "{path}%(title)s.%(ext)s" {url}'.format(
                              path=DOWNLOADING,
                              url=url)),
                          shell=True)
    # Wait for download to finish
    p.communicate()


# Download video + auto-generated captions
def dl_with_auto_cc(url):
    p = subprocess.Popen(('youtube-dl '
                          '-f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best" '
                          '--merge-output-format mp4 '
                          '--write-auto-sub '
                          '--sub-lang en '
                          '--restrict-filenames '
                          '--prefer-ffmpeg '
                          '-o "{path}%(title)s.%(ext)s" {url}'.format(
                              path=DOWNLOADING,
                              url=url)),
                          shell=True)
    # Wait for download to finish
    p.communicate()


def move_file(filepath):
    shutil.move(filepath, DOWNLOAD_LOCATION)


# Remove downloads/encodes so we can start another
def cleanup():
    try:
        shutil.rmtree(DOWNLOADING)
        shutil.rmtree(ENCODING)
    except OSError:
        pass


def main():
    while True:
        cleanup()
        clear()
        intro_message()
        url = get_url()
        captions = get_captions()
        auto_captions = get_auto_captions() if captions else False
        make_dirs()
        download(url, captions, auto_captions)
        files = get_files()
        if captions:
            files = burn_captions(*files)
        move_file(filepath=files[0])
        cleanup()


if __name__ == '__main__':
    main()
\$\endgroup\$
2
\$\begingroup\$

From a first glance it looks okay, but there many things that can be written in a nicer / more idiomatic way.

The choice of using subprocess is completely fine - for the reason you mentioned, but also since libraries depending on their interface can also be much harder to use than their command line counterparts. Note that you don't seem to use the more complex features of subprocess, so perhaps os.system will suffice for you (at the moment) to save the reader some time.


  • Python 3 compatibility would be good; in particular using print() as a function instead of a statement springs to mind.
  • The comments above the functions are almost docstrings, they just have to be put into the functions - docstrings are a good idea to have documentation easily available in the REPL.
  • Instead of the os.path.join call I'd probably do the expansion inline as the path with the ~ included is more amenable to putting it into a configuration file or exposing as a command line argument. Btw. though the calls as they are work thanks to how the join function works, joining HOME over and over again looks wrong, it should be:

    DOWNLOAD_LOCATION = os.path.expanduser('~/Desktop/YT_Downloads/')
    DOWNLOADING = os.path.join(DOWNLOAD_LOCATION, '.downloading/')
    ENCODING = os.path.join(DOWNLOAD_LOCATION, '.encoding/')
    

    If that's not what you meant replace the DOWNLOAD_LOCATION with HOME of course.

  • The SUPPORTED_SUBS list can be a set, that rarely matters though.
  • For the clear function there are probably better ways (using escape codes) but it's probably alright here.
  • In make_dirs it should be most likely os.makedirs as not every user might have ~/Desktop in the first place.
  • AFAIK the CPython interpreter doesn't optimise tail recursion, so calling the function itself like in get_url is bad and in this case also wrong as no value is returned, respectively the first wrong value is returned. Consider using a loop, e.g.:

    def get_url():
        "Prompt user to enter YouTube link"
        while True:
            url = raw_input('Enter YouTube link: ')
            if check_url(url):
                return url
            print('Something went wrong, please ensure you entered a valid URL.')
    
  • In get_captions and get_auto_captions the return value should be the value from the test immediately. The current code reads almost like if True == True:, it's just unnecessary complexity. Also consider moving this into a function like yes_or_no('<prompt here>').

  • The dl_... functions are mostly duplicated code, better to have common arguments somewhere in a constant and then just add the additional things with format.
  • I don't quite get the calling convention for burn_captions - if you have just two values, write it that way. The whole block in main looks like it can fail at any moment given the right filenames or something - if get_files doesn't match anything there will be an exception raised just because of the None values, thus terminating the whole program.

With regards to your concerns:

  1. I'd add more checks. Like catch exceptions everywhere, print nice messages to inform the user of their mistake (or whatever operation failed) and so on. Also check the status codes of the external programs, that's completely missing here.
  2. So as long as you have modular functions and keep in mind that you have separate processes running and you have a plan to pass in user input in some other way than by querying like get_url/get_captions/get_auto_captions currently do, then it should be relatively easy to adapt.

Good job though, hope you can develop this further (or rather, already have, considering the time that has since passed).

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Thanks for the in-depth reply! Could you elaborate on how to make my main() better? I'm not sure I understand the comment "looks like it can fail at any moment given the right filenames". \$\endgroup\$ – Spencer Sep 3 '16 at 18:22
  • \$\begingroup\$ If there's no match for the files there will be an exception, so I'd rather prevent that situation (by checking for no matches); alternatively exceptions could be caught in general for each loop and report it without termination. \$\endgroup\$ – ferada Sep 3 '16 at 18:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.