Plex plugins and my simple Full30 plugin

Plex plugins and my simple Full30 plugin

Let me preface this with Full30 is an awesome site and you should definitely go there if firearms are your thing. I only wrote this Plex addon because I do most of my video watching on the TV and this makes it easier until there is an official app/channel for Roku, AmazonTV, etc. Unfortunately, due to how many companies view firearm videos, and really, the community as a whole, I don't have much hope for any app/channel to stay available for long in any official app store.

If you haven't heard of Plex, it is a multimedia service you install and it lets you access your media content from pretty much any device. For example, you can use Plex DVR and an HD antenae to record OTA TV shows and stream them to your phone when you're not home. It is a pretty awesome service.

Plex currently has the ability to use plugins (aka Channels) that let you stream additional content, some of these plugins do things like stream videos from CNN, CBS, etc. You can even write your own plugins in Python 2.7, but the documentation for doing so is extremely lacking.

There are a few random guides out there, but an actual API reference doesn't seem to exist from what I can see. So, you're left kind of going through forum posts and looking at other plugins to get a better idea of how it all fits together. Good documentation will never come, since Plex has recently announced that they will be discontinuing plugin support, which will likely lead to the plugin framework slowly fading away and end up completely not working. Sucks for those of us that use plugins to add the ability to do things not included in the stock Plex install right?

Anyway, even though plugin support is being discontinued, I've been still trying to maintain a Plex plugin I created called 'Unofficial Plex Channel for Full30.com'. If you're into firearms, you've probably heard of the website full30.com, and if you haven't heard of it, you should check it out. It is a site that is 100% dedicated to firearm videos by many of the top content creators on YouTube that create videos about firearms. They've been slowly migrating to full30.com due to YouTube's recent push to start removing or demonetizing videos that are related to firearms or firearms culture.

At the moment, you can only browse and watch videos from Full30 via your browser, so if you're like me, and you generally do most of your video watching on your TV, this becomes a big of a problem. I could start subscribing to each of these content creators on YouTube, then start sorting through them in the YouTube app, but this can become a pain if you subscribe to so many channels like I do, and it is nice that Full30 has all of these content creators in a single place. I've heard rumors that the creators of Full30 are working on a mobile app, but I'm not sure if they are working on an app for Amazon FireTV or Roku, but I doubt they will ever have an app for Xbox One.

Since there is currently no app to easily view Full30 content, I decided to take a stab at creating a Plex plugin (channel) that lets me easily view the content on my Xbox using the Plex app.

Plex plugins are all written in Python 2.7, and it has been years since I've written anything useful in Python. Probably five years ago I went through Learning Python the Hard Way, wrote a few quick GroupMe bots and never really did anything else with it, so the knowledge of Python quickly faded away. To be honest, Windows is my primary development OS, and I do have a Ubuntu server, but most of the projects I work on tend to lean towards .NET (or NodeJS if I feel like doing something cross-platform). Creating a Plex plugin was a good chance for me to get my feet wet with Python again while producing an end result that would be useful to me.

When I first wrote this Full30 Plex channel, I was scrapping the full30.com website for the channels list, then scraping each channel's page for the videos lists. This worked, but was slow and very prone to errors since the site changed pretty frequently. After a long break, I decided to start using Chrome's Developer Tools to start seeing if the website was using any sort of API to get a list of channels and video information. It was!

I've been using the APIs for awhile to get the plugin to work, but recently I noticed it wasn't working anymore because they changed some of the APIs, so I had to spend some time back in Chrome's Developer Tools to figure out what was changed. I got the plugin working with the latest changes, so I figured I would do a post about this plugin. I am absolutely horrible with Python, I completely admit that. I don't spend enough time writing anything in it, so I just kind of hack through what I need to get done. You're going to see stuff done the wrong way, and I am OK with that right now since this is really just a learning project.

Lets get started. First I had to get a list of channels on the website. I found that you can make a call to this API and get a paginated list in JSON of each channel:

https://www.full30.com/api/v2.0/channels?page=1

So for example, you might get results that look like this:

{  
   data:[  
      {  
         banner_filename:"9e0ed0307603180e02603c344118d334.jpg",
         description:"Military style small arms are an important part of our culture and freedom. We celebrate military history and our 2nd Amendment rights here at MAC. Join us!",
         id:1,
         intro_video_id:18362,
         profile_filename:"be6e8e959a309c4f3a989bea402f56ef.jpg",
         published:true,
         slug:"mac",
         subscriber_count:32349,
         title:"Military Arms Channel"
      },
      {  
         banner_filename:"b4df893223d92e0f5d4e54248fc1baf0.jpg",
         description:"Who doesn't like trick shots?!?! My channel consists of a variety of .22 trick shots with a few hunting and fishing videos showing up here and there. I am not a professional shooter but I am a shooting enthusiast!! If you like my videos, please subscribe to my channel. A new trick shot every week!",
         id:2,
         intro_video_id:142,
         profile_filename:"f7b67d470701ee067cda2f13df34d570.jpg",
         published:true,
         slug:"22plinkster",
         subscriber_count:15503,
         title:"22Plinkster"
      }
   ],
   message:"Channels matching query",
   meta:{  
      order:"asc",
      page:1,
      pages:134,
      per_page:2
   },
   status:"success"
}

Each channel has a unique ID and a slug, which are used later to fetch a list of recent videos for that channel.

I use the information returned by this request to generate a folder list in Plex for each channel. The code is pretty horrible, I apologize.

@route(ROUTE + '/AllChannels')
def ListChannels(title, page = 1):
    oc = ObjectContainer(title2 = title, view_group='InfoList')

    channels = get_channels(page)
    limit = channels['pages']

    for channel in channels['channels']:
        title = channel['name']
        thumbnail = channel['thumbnail']
        url = channel['url']
        slug = channel['slug']

        Log.Info('ListChannels - {0}; Slug={1}; Url={2}; Thumb={3}'.format(title, slug, url, thumbnail))

        oc.add(DirectoryObject(
            key =
			Callback(
			    Channel_Menu,
			    title = title,
			    channel_url = url,
                slug = slug,
                thumbnail = thumbnail
		    ),
		    title = title,
            thumb = Callback(GetThumb, url = thumbnail)
		))
    
    next_page = int(page) + 1
    if next_page <= limit:
        oc.add(DirectoryObject(
            key =
            Callback(
                ListChannels,
                title = 'All Channels - Page {0}'.format(next_page),
                page = next_page
            ),
            title = 'Page {0}'.format(next_page),
            summary = 'View more channels'
       ))

    return oc
                         
BASE_URL                = 'https://www.full30.com'  
CHANNELS_URL            = BASE_URL + '/api/v2.0/channels?page={0}&per_page=25'
                         
def get_channels(page = 1):
    channels = { 'pages' : '', 'channels' : [] }
    
    channel_url = CHANNELS_URL.format(page)

    html = GetPage(channel_url)
    
    if not html:
        return None
        
    data = json.loads(html)
    
    if not data:
        return None

    channels['pages'] = data['meta']['pages']

    for video in data['data']:
        channel_url = BASE_URL + "/channels/" + video['slug']

        channel_name = video['title']
        channel_thumbnail = '' if video['profile_filename'] is None else BASE_URL + "/cdn/c/b/" + video['profile_filename']
        channel_desc = video['description']
        channel_banner = '' if video['banner_filename'] is None else BASE_URL + "/cdn/c/b/" + video['banner_filename']
        channel_subscribers = video['subscriber_count']
        channel_slug = video['slug']

        channels['channels'].append(
            { 
                "name" : channel_name, 
                "url" : channel_url, 
                "thumbnail" : channel_thumbnail,
                "desc" : channel_desc,
                "banner" : channel_banner,
                "subscribers" : channel_subscribers,
                "slug" : channel_slug
            })

    return channels

This will generate a list of each channel, including the channel's thumbnail image.

When you click on the channel, it will ask if you want to view 'Most Viewed' or 'Recent Videos'.

Most Viewed pulls from the most viewed by channel API:

https://www.full30.com/api/v1.0/mostviewed/{channel-slug}?page=1

This API returns a json list that contains the details for the most viewed videos for the specified channel slug:

{  
   channel:{  
      id:1,
      slug:"mac",
      title:"Military Arms Channel"
   },
   pages:34,
   videos:[  
      {  
         b64_identifier:"MDE0NjYw",
         hashed_identifier:"684f66af2db2bba0222b4ec099f61846",
         id:14660,
         thumbnail_filename:"610465",
         title:"Hi-Point 10mm Carbine the new 1095",
         version:1
      },
      {  
         b64_identifier:"MDE3MzY1",
         hashed_identifier:"06bce1ef0ce7b1c368a09e3f08eee238",
         id:17365,
         thumbnail_filename:"191924",
         title:"My Get Home Bag",
         version:1
      },
      {  
         b64_identifier:"MDE2NTg0",
         hashed_identifier:"b75c022ea6023f59b0180cca7746c998",
         id:16584,
         thumbnail_filename:"831896",
         title:"Affordable Gun Challenge: Walther PPX vs. Taurus PT92",
         version:1
      }
   ]
}

Each video has an ID, base64 id and a hash. These are all used to identify the video, and we use them to get the video's details, such as the full url to the video thumbnail, and the video stream.

To get the url for video thumbnail image, we use the channel's slug, the 'thumbnail_filename' and the 'hashed_identifier' values found in the results above and build a url like so:

https://www.full30.com/cdn/videos/{channel-slug}/{hashed_identifier}/thumbnails/320x180_{thumbnail_filename}.jpg

This method might change in the future, but for now it is working fine.

For the actual video stream details, we use the value of the video's ID from the results above and call the API below:

https://www.full30.com/api/v2.0/videos?filter_id={video-id}

So for the first video returned by the most viewed API results we would make a call to this API:

https://www.full30.com/api/v2.0/videos?filter_id=14660

The results would contain the details for the video, including published date, tags, description and a link to the mp4 file for the video:

{  
   data:[  
      {  
         channel:{  
            profile:"be6e8e959a309c4f3a989bea402f56ef.jpg",
            slug:"mac",
            title:"Military Arms Channel",
            verified:false
         },
         images:{  
            poster:"/cdn/videos/mac/684f66af2db2bba0222b4ec099f61846/thumbnails/854x480_610465.jpg",
            thumbnails:[  
               "/cdn/videos/mac/684f66af2db2bba0222b4ec099f61846/thumbnails/320x180_610465.jpg"
            ]
         },
         meta:{  
            b64_id:"MDE0NjYw",
            comment_count:0,
            description:"Hipoint released their new 10mm carbine. It is a blowback gun just like previous generations and calibers,
            but this one chambers the 10mm Auto,
            which is no slouch. There aren't many 10mm blowback firearms out there and certainly not for this price.",
            enable_comments:true,
            featured:false,
            hashed_identifier:"684f66af2db2bba0222b4ec099f61846",
            id:14660,
            mature_content:false,
            publication_date:"11/27/2018",
            recommended:false,
            scheduled_time:"Null",
            slug:"hi-point-10mm-carbine-the-new-1095",
            thumbnail_filename:"610465",
            title:"Hi-Point 10mm Carbine the new 1095",
            trailer:false,
            version:1,
            view_count:3409
         },
         playlists:[  

         ],
         status:{  
            processed:true,
            publication_status:{  
               id:4,
               title:"published"
            },
            published:false,
            uploaded:true
         },
         taxonomy:{  
            category:"Firearm Reviews",
            category_id:2,
            category_slug:null,
            tags:[  
               {  
                  id:1151,
                  name:"1095",
                  slug:"1095"
               },
               {  
                  id:1148,
                  name:"problem solver",
                  slug:"problem-solver"
               },
               {  
                  id:1145,
                  name:"carbine",
                  slug:"carbine"
               },
               {  
                  id:1144,
                  name:"hipoint",
                  slug:"hipoint"
               },
               {  
                  id:1141,
                  name:"10mm",
                  slug:"10mm"
               },
               {  
                  id:1116,
                  name:"",
                  slug:""
               }
            ]
         },
         transforms:{  
            mp4:{  
               resolutions:{
              854x480:"//videos.full30.com/bitmotive/public/full30/v1.0/videos/mac/684f66af2db2bba0222b4ec099f61846/854x480.mp4"
               }
            }
         }
      }
   ],
   message:"Videos matching query",
   meta:{  
      order:"asc",
      page:1,
      pages:1,
      per_page:16
   },
   status:"success"
}

I've noticed that some videos urls contain 'https:' and some don't, so I do some checking and add the 'https:' if it isn't found.

Recent Videos pulls from the recent videos by channel API:

https://www.full30.com/api/v1.0/channel/{channel-slug}/recent-videos?page=1

The plugin processes the results of this query much like what it does for the most viewed results.

This plugin uses a URL Service to handle meta data and playing the video. The plugin is set to handle the url 'https://www.full30.com/api/v2.0/videos?filter_id={video-id}', so each VideoClip that is added in the plugin will be set to have a url that contain's this API url.

I have noticed that some videos do not actually contain a valid url to the mp4, and in these cases, the url has a '/None/' value in its path, so the URL Service specifically checks for this and builds a new url for the mp4 stream. This took some debugging in Chrome's Developer Tools, but right now, it appears to find these and correct the mp4 stream url.

Here is what the URL Service looks like:

import httplib
import json
from datetime import datetime
from scrape import GetPage as GetPage
from scrape import GetThumb as GetThumb
from utils import RemoveTags as RemoveTags

def MetadataObjectForURL(url):
    Log.Info('MetadataObjectForURL  url = ' + url)

    content = GetPage(url)

    if not content:
        return None
        
    data = json.loads(content)
    
    if not data:
        return None

    title = data['data'][0]['meta']['title']
    description = data['data'][0]['meta']['description']
    pub_date = datetime.strptime(data['data'][0]['meta']['publication_date'], '%m/%d/%Y')

    thumb = data['data'][0]['images']['thumbnails'][0]
    if thumb.startswith('http') == False:
        thumb = 'https://www.full30.com' + data['data'][0]['images']['thumbnails'][0]

    # Remove markup from desc
    description = RemoveTags(description)

    return VideoClipObject(
        title = title,
        summary = description,
        thumb = Callback(GetThumb, url=thumb),
        originally_available_at = pub_date.date(),
	    year = pub_date.year,
        )     

def MediaObjectsForURL(url):
    Log.Info('MediaObjectsForURL - url = ' + url)

    return [
        MediaObject(
            video_codec = VideoCodec.H264,
            audio_codec = AudioCodec.AAC,
            container = Container.MP4,
            audio_channels = 2,
            optimized_for_streaming = True,
            parts = [PartObject(key=Callback(PlayVideo, url = url))]
        )
    ]

@indirect
def PlayVideo(url):
    Log.Info('PlayVideo - url = ' + url)

    video_url = GetVideoUrl(url)

    if not video_url:
        raise Ex.MediaNotAvailable

    Log.Info('PlayVideo: video_url = ' + video_url)

    return IndirectResponse(VideoClipObject, key=video_url)

def GetVideoUrl(url):
    Log.Info('GetVideoUrl - url = ' + url)

    content = GetPage(url)

    if not content:
        Log.Info('GetVideoUrl: no content returned')
        return None
        
    data = json.loads(content)
    
    if not data:
        Log.Info('GetVideoUrl: no json found')
        return None

    mp4_url = "https:" + data['data'][0]['transforms']['mp4']['resolutions']['854x480']

    # If mp4_url contains /None/ then we have to try to get the video url from another source
    if "/None/" in mp4_url:
        b64_id = data['data'][0]['meta']['b64_id']

        outside_url = "https://preflight.epicio.net/api/manifest/{0}".format(b64_id)

        Log.Info('GetVideoUrl: video url seems incorrect, trying new url; url = ' + mp4_url)

        outside_content = GetPage(outside_url)

        if not outside_content:
            Log.Info('GetVideoUrl: outside url contains no data')
            return None
        
        outside_data = json.loads(outside_content)
        if not outside_data:
            Log.Info('GetVideoUrl: outside url contains no json')
            return None

        # check if status is valid
        if outside_data['meta']['status'] == "SUCCESS":
            mp4_url = outside_data['resolutions'][0]['src']
        else:
            return None

    return mp4_url

I'm not going to post the code for the rest of the bits, since you can just go to the repository on GitHub for all that.

The full code for this Plex channel can be found here, with instructions on how to install it: https://github.com/jpann/Full30.bundle

Here is a demo of the plugin in action: