Let's build a `CLI Based YouTube Launcher` in Python
Tags: youtube, python, automation, project
Published: 2020-08-15 16:16:35

---
title: Let's build a `CLI Based YouTube Launcher` in Python
published: true
description: An afternoon side project, to automate the launching of favorite YouTube channels from the command line.
tags: youtube, python, automation, project
cover_image: http://live.staticflickr.com/65535/49956396622_1884ffcc24_b.jpg
---

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. 

* <h2> Features identification. <h2/>

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.*

<h4> Basic Commands. </h4>

*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
```


* 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.

___________________________


<h2> Data Storage </h2>

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.*

```JSON
{
   "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"
      }
   ]
}
```
___________________________

*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.*

<h3> There are three main modules. </h3>

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.
_____________________________________________________

<h1> 1) yt.py </h1>

*Dependencies for yt.py*

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

#Custom Imports
from CLIArgsParser import CLIArgsParser
from YoutubeLauncher import YoutubeLauncher
```

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.

```python
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)

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

```python
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()
```
**loadChannelDataFromFile()** Loads the channels data from the JSON file.

```python
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 
```

* [Github Link of yt.py](https://github.com/djzaamir/YoutubeLauncher/blob/master/yt.py)

______________________________________

<h1> 2) CLIArgsParser.py </h1>

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 <b>data</b> will contain the error generated by the parser.*

* **-c**       *Indicates a channel launch command, <b>data</b> will be the name of the channel to launch.* 

* **-g**       *Indicates a group launch of channels command, <b>data</b> will contain group keyword/name* 

* **-l**        *Indicates a list flag, <b>data</b> will be empty for this one.* 
 
You can add more flags in this section according to your needs.   

```python
# 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

```
* [Github Link of CLIArgsParser.py](https://github.com/djzaamir/YoutubeLauncher/blob/master/CLIArgsParser.py)


___________________________

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.

<h1> 3) YoutubeLauncher.py </h1>

*Dependencies*
```python
from fuzzywuzzy import fuzz # For String Matching operations
import webbrowser
from colored import bg, attr # FOr printing pretty console output
```

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 open`URLS` 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`*

```python
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"
```

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. 

```python
 # 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()
```

 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*

```python
  

    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
```
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.

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

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

* [Github Link of YoutubeLauncher.py](https://github.com/djzaamir/YoutubeLauncher/blob/master/YoutubeLauncher.py)

* [Github Link of Complete Project](https://github.com/djzaamir/YoutubeLauncher)



___________________________

<h2> Final thoughts </h2>

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.
> *<Secret Life Of Walter Mitty>*