DEV Community

loading...
Cover image for Let's build a `CLI Based YouTube Launcher` in Python

Let's build a `CLI Based YouTube Launcher` in Python

Aamir
Passionate Computer Programmer, Who love to deal with All things CS.
・9 min read

Hey Guys, I wanted to build and share a simple python based command-line utility that I put together for personnel use. This is a small fun little project which you can put together as well in an afternoon or morning in case you're an early bird. So grab a snack and let's dive right in.


  • The Problem (Hint, My Laziness)

I found myself constantly visiting some of my favorite YouTube channels, sometimes multiples times a day. And as you could imagine it involves quite a lot of typing (Says, my inner lazy programmer). So I automated this tedious process, below I've explained the approach I took to develop a CLI based solution for the above-described scenario.

  • Features identification.

I restricted my self to three commands to meet our primary goals with this utility, but once developed this can easily be extended to add other useful features.

I have developed and tested this utility to work on a Linux machine, but it can be very easily ported to a windows machine.

Basic Commands.

yt.py is our main command-line utility.

1) yt.py Linus tech tips
2) yt.py -g space
3) yt.py -l

  Side note, on a windows system, you would have to prefix yt.py
  with a python interpreter, somewhat like the following.

1) python yt.py Linus tech tips 
2) python yt.py -g space
3) python yt.py -l
Enter fullscreen mode Exit fullscreen mode
  • The first command is the most basic use case, and maybe the most useful one, in which a user may type a channel name (it does not have to match exactly, just as close as possible) after our main yt command.

  • Next variant is slightly more interesting, where we are allowing a user to launch a group of channels with a single keyword after -g (group flag).

  • -l (list flag) will simply list the channels currently being tracked by our small utility as well as some meta-data tied to them.


Data Storage

I've decided to go with a JSON based storage structure, just because it's quite easy to incorporate in our codebase, and the majority of the developers are familiar with its syntax. If you'd like you can choose to use some other storage structure e.g YAML.

JSON file contains a list of channels, where each channel has, name, ,URL and group fields. You can have multiple groups defined with spaces in between them.

Sample JSON file that I will be using for this project.

{
   "channels" : [
      {
       "name" : "Linus tech tips",
       "url" : "https://www.youtube.com/c/LinusTechTips/videos",
       "groups" : "tech technology"
      },
      {
       "name" : "Marques Brownlee MKBHD",
       "url" : "https://www.youtube.com/c/mkbhd/videos",
       "groups": "tech technology"
      },
      {
       "name" : "Tim dodd The Everyday Astronaut",
       "url" : "https://www.youtube.com/c/EverydayAstronaut/videos",
       "groups" : "space rockets"
      },
      {
       "name" : "SpaceX",
       "url" : "https://www.youtube.com/c/SpaceX/videos",
       "groups" : "space rockets"
      }
   ]
}
Enter fullscreen mode Exit fullscreen mode

For the sake of better understanding, I have broken up the code into small snippets. I have heavily commented out the code, to point out important details of implementation.

There are three main modules.

1) yt.py (Responsible for CLI parameters gathering, JSON data loading from file, and linking modules).

2) CLIArgsParser.py (Parses CLI arguments, and chooses appropriate action). More on this in the coming sections.

3) YoutubeLauncher.py (Executes the action chosen by CLIArgsParser.py).

Let's dive into each module one by one. I will be describing class methods individually and will add Github links at the end of each section.


1) yt.py

Dependencies for yt.py

#!/usr/bin/python
import sys   
import json  
import os

#Custom Imports
from CLIArgsParser import CLIArgsParser
from YoutubeLauncher import YoutubeLauncher
Enter fullscreen mode Exit fullscreen mode

We begin with some basic level validations of command line parameters, making sure that the user is using the utility in the right manner, else we display some helpful text in regards to the usage of our app.

if ( __name__ == "__main__"):
    args = sys.argv

    # Make sure at least 2 parameters (filename + channel name) are provided
    args_good = True
    if (len(args) < 2):
        help_txt = " Please provide proper arguments, E.g\n\n  1) yt <channel name> \n    Example => yt linus tech tips\n\n  2) yt -g <group name>\n    Example yt -g technology\n\n  3) yt -l # To list channel names with meta data."
        print(help_txt)
        args_good = False

     # Tidy up the parameters for the rest of the code
    if(args_good):
        #Transform text to lowercase
        args = list(map(lambda x: x.lower(), args)) 
        #Skip filename
        args = args[1 : len(args)] 
        main(args)

Enter fullscreen mode Exit fullscreen mode

main() is responsible for gluing together different parts of the application, it invokes different modules and interchange data between them.

def main(args):

    #Load channel data
    channels_data_json = loadChannelDataFromFile()["channels"]
    if channels_data_json == None:
        return


    # Parse Arguments with our custom CLI parameters parser
    # It's not the best implementation of a CLI parser out there
    # But it will do our job just fine.
    cliParser = CLIArgsParser()

    # In case of successful parsing, we will get an array containing
    # [action, data-required-to-perform-this-action] structure
    # This [action, data] can be to launch a single channel or a group of channels or to just simply list channels.
    # For more information, please keep reading, CLIArgsParser is described in much more detail below.
    command_data = cliParser.parseCLIArguments(args)

    #If a Critical Error Occured
    if (command_data[0] == "-e" ):
        #Print the error and stop execution
        print(command_data[1])
        return

    # Perform action, using YoutubeLauncher
    # YoutubeLauncher Requires following parameters
    #   1) Channels data
    #   2) command_data, which contains the [action,data] structure essentially letting it (YoutubeLauncher) know what to do
    youtube_launcher = YoutubeLauncher(channels_data_json , command_data)

    #Execute
    youtube_launcher.launch()
Enter fullscreen mode Exit fullscreen mode

loadChannelDataFromFile() Loads the channels data from the JSON file.

def loadChannelDataFromFile():
    filename = os.path.join(os.path.dirname(__file__) , "channels.json")
    try:
        with open(filename, 'r') as channels_data_file:
            return json.load(channels_data_file)
    except IOError as error:
        print("{}".format(error.message))
        return None#Stop Execution 
Enter fullscreen mode Exit fullscreen mode

2) CLIArgsParser.py

This is our homegrown simple command-line arguments parser module (it's not perfect). Its primary job is to generate and return an array of action and data structure like [action, data] based upon provided parameters. This [action, data] structure is a requirement for our YoutubeLauncher.py which operates on it. This parsed structure lets the YoutubeLauncher.py module know about, what the user has asked for and what it is supposed to do. It (CLIArgsParser.py) also raises relevant parsing errors and lets the user know if the given parameters are not in compliance.

Following are some of the actions, and what they mean.

  • -e Indicates that some error has occurred while parsing. In this case the data will contain the error generated by the parser.

  • -c Indicates a channel launch command, data will be the name of the channel to launch.

  • -g Indicates a group launch of channels command, data will contain group keyword/name

  • -l Indicates a list flag, data will be empty for this one.

You can add more flags in this section according to your needs.

# Responsible For Parsing Command-Line parameters 
# And Generating [action, data] structure
class CLIArgsParser:


    #Returns an Array containing two items
    # 1) Command, this could be -c for single channel launch, -g for group launch or -l to only list channels, finally -e if there were any errors during parsing
    # 2) Data, this is the data required for the said command
    # 3) For Example, [-g , "technology"], will indicate to channels group with keyword `technology`
    def parseCLIArguments(self,args):

        CHANNEL_FLAG = "-c"
        GROUP_FLAG   = "-g"
        LIST_FLAG    = "-l"
        ERROR_FLAG   = "-e"

        #Handle single channel launch, Consider Everything is Query, join and return
        if(self.noFlagsProvided(args)): 
            return [CHANNEL_FLAG , ' '.join(args)]

        # Code does not handle both cases (-l and -g) at the same time, RAISE_ERROR
        elif (self.bothFlagsProvided(args)): 
            return [ERROR_FLAG, "ERROR: -g and -l flags can't be provided simultaneously\nPlease only provide one flag at a time."]

        #Handle group case, extract group query string 
        elif ("-g" in args): 
            group_query_index = args.index("-g") + 1

            # Check if group query exists after -g flag
            if(group_query_index < len(args)): 
                return [GROUP_FLAG , args[group_query_index]]
            else: #Raise Error
                return [ERROR_FLAG , "ERROR : -g flag provided without specifying group"]

        # Handle channel listing case        
        elif ("-l" in args):
            return [LIST_FLAG,None]


        else:
            return [ERROR_FLAG, "ERROR: Unknown error occurred while parsing arguments."]



    #Helper Methods
    def noFlagsProvided(self,args):
        return "-l" not in args and "-g" not in args

    def bothFlagsProvided(self, args):
        return "-l" in args and "-g" in args

Enter fullscreen mode Exit fullscreen mode

Ok so at this point a lot of the work has been done. All that is left is the execution of our [action, data] by YoutubeLauncher.py module.

3) YoutubeLauncher.py

Dependencies

from fuzzywuzzy import fuzz # For String Matching operations
import webbrowser
from colored import bg, attr # FOr printing pretty console output
Enter fullscreen mode Exit fullscreen mode

This module is in-charge of, actually executing the [action, data] structure which is generated by CLIArgsParser.py module, along with this it also receives the list of channels from the yt.py module.

fuzzywuzzy is a very good python module, for string matching operations, it has many methods depending on your requirements, whether you'd like to have full string matches or partial string matches. It returns a percentage depending on the amount of match.

webbrowser is another handy module, it allows us to quickly openURLS in a browser window, it can also work with different browsers.

Last but no the least, colored. Well, we are using this one just for the sake of some aesthetics, to print out some colored console output.

Let's break up YoutubeLauncher.py

class YoutubeLauncher:

    def __init__(self , channels_data_json, action_data, match_percentage = 65 ):

        # Store Channel data and [action, data] structure
        self.channels_data_json = channels_data_json
        self.action_data = action_data
        self.match_percentage = match_percentage

        #Expected action/command flags
        self.CHANNEL_FLAG = "-c"
        self.GROUP_FLAG   = "-g"
        self.LIST_FLAG    = "-l"
Enter fullscreen mode Exit fullscreen mode

There is nothing much going on in the constructor, we are basically storing the list of channels and the [action, data] structure, defining some flags and that's pretty much it. However, there is this match_percentage parameter which I'd like to point out. By default, I have set it to 65, but you can change it if you want to. It essentially sets the bar for the matching percentage between two strings, if you set it too low then it will start matching channel names and groups even if they do not really match. So it is something you can play with.

 # Core Method, will call other supporting methods implicitly
    def launch(self):
        command  = self.action_data[0]
        data = self.action_data[1] 

        #IF Parser has specified a single channel launch action/command    
        if (command == self.CHANNEL_FLAG):

            matching_channel = self.__findBestMatchingChannel(data)
            if matching_channel != None:
                self.__initiateYoutubeLaunch(1, matching_channel)
            else:
                print("No Matching channel found.")

        #if parser has specified a group launch action/command
        elif (command == self.GROUP_FLAG):
            matching_channels = self.__findChannelsWithBestMatchingGroup(data)
            if (len(matching_channels) > 0):
                i = 1
                for matching_channel in matching_channels:
                    self.__initiateYoutubeLaunch(i, matching_channel)
                    i += 1
            else:
                print("No Matcing groups found.")

        #if parser has specified list action/command
        elif(command  == self.LIST_FLAG):
            self.__listAvailableChannelData()
Enter fullscreen mode Exit fullscreen mode

launch(), reads the [action, data] structure, and calls supporting methods accordingly. It does this by matching the action flag parameter, which is at index 0, of [action, data] array with its own internally defined flags.

Supporting Methods



    def __findChannelsWithBestMatchingGroup(self, group_names):
        matching_channels = []
        for channel in self.channels_data_json:
            group_matching_score = fuzz.partial_token_set_ratio(group_names, channel["groups"])
            if(group_matching_score >= self.match_percentage):
                matching_channels.append(channel)

        return matching_channels

    def __findBestMatchingChannel(self, channel_name):

        best_match_channel = None
        channel_with_max_score = 0

        for channel_index in range(0 , len(self.channels_data_json)):

            current_channel = self.channels_data_json[channel_index]
            local_score = fuzz.partial_token_set_ratio(channel_name, current_channel["name"])

            if(local_score > channel_with_max_score and local_score > self.match_percentage):
                best_match_channel = current_channel
                channel_with_max_score = local_score

        return best_match_channel


    def __listAvailableChannelData(self):
        i = 1
        for channel in self.channels_data_json:
            data_to_print = "{}) {} {} {}\n      URL = {}\n      Groups = {}".format(i, bg(25) ,channel["name"], attr(0), channel["url"], channel["groups"])
            print(data_to_print)
            i += 1
Enter fullscreen mode Exit fullscreen mode

These methods are called directly against -c, -g, or -l flags.

def __findBestMatchingChannel(self, channel_name):
will return a single matching channel along with all of its meta-data.

def __findChannelsWithBestMatchingGroup(self, group_names):
will return a list of channels with a matching group along with meta-data.

def __listAvailableChannelData(self):

Will only print names and meta-data about channels on the console.

 def __initiateYoutubeLaunch(self,counter, channel):
        print("{}) {} Launching {} {}\n".format(counter, bg(25), channel["name"], attr(0)))
        webbrowser.open_new_tab(channel["url"])
Enter fullscreen mode Exit fullscreen mode

And Finally, def __initiateYoutubeLaunch(self,counter, channel):
will call the webbrowser module to open URL in a new tab.


Final thoughts

I had a lot of fun working on this side project and I hope that you will too. Projects like these are not only a good way to polish up your coding skills, but also an integral part of your journey as a developer. To take some problem in your daily life and automating it with code. After all, this is what programming is for, to automate stuff and making your life and the life of others around you just a little bit more easier.

Chao, Aamir out.

To see the world, things dangerous to come to, to see behind walls, draw closer, to find each other, and to feel. That is the purpose of life.

Discussion (0)