For a while now I’ve been ripping my old DVD’s and Blu-rays to hard drive storage. In my entire home I have a total of 3 drives that can read discs and two of them are in video game consoles, so it’s not really easy to pull my Futurama season 4 discs out, find episode seven, and get to crying quickly. It’s easier to just search on a computer for “Jurassic Bark” and get my tears started quickly.
But converting series names, seasons, and episode numbers to a name can be challenging if you are doing it by hand. So I decided to take a little time and see what resources I have online. What I found was TheTvDB. It’s a great resource, mostly maintained by users which lists all the pertinent details about a specific show. The search is great, but I wanted a better way to automate it if possible. Enter PowerShell.
After reading a little bit on how their API worked, I decided to try my hand at writing calls directly to their data. The first thing I needed to do was build an Authentication Header (my words, not theirs) that I send on every call to the API.
<#
.Synopsis
Build an authentication header for use with theTvDb's API
.DESCRIPTION
More information about working with the API and how to find these keys can be found here: https://www.thetvdb.com/api-information
.EXAMPLE
Get-TvDbAuthenticationHeader -ApiKey 'YourAPIKey' -UserKey 'YourUserKey' -Username 'YourUserName'
Name Value
---- -----
Accept application/json
Authorization Bearer ABunchOfStuffHereThatsImportant
.EXAMPLE
Get-TvDbSeriesName -Name "Limitless" -ExactMatch -Authorization ( Get-TvDbAuthenticationHeader -ApiKey 'YourAPIKey' -UserKey 'YourUserKey' -Username 'YourUserName' )
id seriesName
-- ----------
295743 Limitless
#>
function Get-TvDbAuthenticationHeader
{
[CmdletBinding()]
Param
(
# Api Key Help
[Parameter(Mandatory=$true,
ValueFromPipelineByPropertyName=$false,
Position=0)]
[string]$ApiKey,
# User Key Help
[Parameter(Mandatory=$true,
ValueFromPipelineByPropertyName=$false,
Position=1)]
[string]$UserKey,
# Username Help
[Parameter(Mandatory=$true,
ValueFromPipelineByPropertyName=$false,
Position=2)]
[string]$Username
)
Begin
{
if ( -not ( Test-Connection -ComputerName api.thetvdb.com -Quiet -Count 1 ) )
{
Write-Error -Message "Cannot connect to theTvDb site. Check your internet connection."
break
}
}
Process
{
$ApiUrl = "https://api.thetvdb.com/"
$Action = "login"
$Headers = @{
"Accept" = "application/json"
}
$Body = @"
{
"apikey": "$ApiKey",
"userkey": "$UserKey",
"username": "$Username"
}
"@
$Login = Invoke-RestMethod -Uri ( $ApiUrl + $Action ) -ContentType "application/json" -Headers $Headers -Body $Body -Method Post
if ( $Login )
{
#retrieve the token
$LoginToken = $Login.token
# build the headers
$Headers = @{
"Accept" = "application/json"
"Authorization" = "Bearer $LoginToken"
}
# Return the headers
$Headers
}
}
End
{
}
}
This is an incredibly boring function, but I find it easier to use than remembering the syntax over and over again. After all, if you’re going to do something a couple of hundred times, it’s probably better to script it.
Next, I needed to work on retrieving a specific series. This was more interesting because there are several series that have replayed over time under the same name (Battlestar Galactica is an excellent example), so I needed to make sure that the search could take that into account. This is what I ended up with:
<#
.Synopsis
Lookup series information on theTvDb via search.
.DESCRIPTION
Makes a call to theTvDb API and returns matches for a name. Requires an authorization header which can be obtained with 'Get-TvDbAuthenticationHeader'
.EXAMPLE
Get-TvDbSeriesName -Name "The Good Place" -Authorization $Authorization
id seriesName
-- ----------
373274 The Good Place: The Podcast
311711 The Good Place
256994 Food Lovers Guide to the Planet
.EXAMPLE
Get-TvDbSeriesName -Name "The Good Place" -Authorization $Authorization -ExactMatch
id seriesName
-- ----------
311711 The Good Place
.EXAMPLE
Get-TvDbSeriesName -Name "The Good Place" -Authorization $Authorization -ExactMatch -ReturnAllData
aliases : {Une chipie au paradis}
banner : /banners/graphical/311711-g.jpg
firstAired : 2016-9-19
id : 311711
image : /banners/posters/311711-3.jpg
network : NBC
overview : Eleanor Shellstrop is an ordinary woman who, through an extraordinary string of events, enters the afterlife where she comes to realize that she hasn't been a very good person.
With the help of her wise afterlife mentor, she's determined to shed her old way of living and discover the awesome (or at least the pretty good) person within.
poster : /banners/posters/311711-3.jpg
seriesName : The Good Place
slug : the-good-place
status : Ended
.EXAMPLE
"The Good Place", "Ducktales", "Shakespeare & Hathaway" | Get-TvDbSeriesName -Authorization $Authorization
id seriesName
-- ----------
373274 The Good Place: The Podcast
311711 The Good Place
256994 Food Lovers Guide to the Planet
75931 DuckTales
330134 DuckTales (2017)
343179 Shakespeare & Hathaway: Private Investigators
.EXAMPLE
"The Good Place", "Ducktales", "Shakespeare & Hathaway" | Get-TvDbSeriesName -Authorization $Authorization -ReturnAllData
aliases : {}
banner :
firstAired : 2018-5-23
id : 373274
image : /banners/images/missing/series.jpg
network :
overview : Holy motherforking shirtballs! This is the official comedy and entertainment podcast for NBC's TV show The Good Place. Subscribe and you'll get weekly behind-the-scenes
stories, episode and performance insights and funny anecdotes. Hosted by actor Marc Evan Jackson (Shawn) with a rotating slate of co-hosts and special guests, including actors,
writers, producers and more, this podcast takes a deep dive into everything on- and off-screen.
poster :
seriesName : The Good Place: The Podcast
slug : the-good-place-the-podcast
status : Continuing
aliases : {Une chipie au paradis}
banner : /banners/graphical/311711-g.jpg
firstAired : 2016-9-19
id : 311711
image : /banners/posters/311711-3.jpg
network : NBC
[.....................................................................................]
[ OUTPUT TRUNCATED ]
[.....................................................................................]
image : /banners/posters/75931-5.jpg
network : Disney Channel
overview : When Donald Duck decides to join the Navy, he leaves his nephews, Huey, Dewey and Louie, in the care of his cantankerous Uncle Scrooge. He is an eccentric and miserly
billionare who loves to literally swim in his money that is held in his corporate headquarters/vault known as the Money Bin. While the initial meeting was less than pleasant,
events soon have them, along with a newly hired nanny, her daughter and Scrooge's stupid but skilled pilot, on countless adventures as the group goes around the world looking
for treasure, or defending Scrooge's current assets from enemies like the Beagle Boys or Magica De Spell.
poster : /banners/posters/75931-5.jpg
seriesName : DuckTales
slug : ducktales
status : Ended
aliases : {Duck Tales (2017)}
banner : /banners/graphical/330134-g3.jpg
firstAired : 2016-12-16
id : 330134
image : /banners/posters/330134-3.jpg
network : Disney XD
overview : Ducktales are the adventures of billionaire Scrooge McDuck and his nephews Huey, Dewey and Louie, their famous uncle Donald Duck, pilot extraordinaire Launchpad, Mrs. Beakly,
Webby and Gizmoduck. Adventures and hidden treasures are everywhere, in their hometown Duckburg and all around the world.
poster : /banners/posters/330134-3.jpg
seriesName : DuckTales (2017)
slug : ducktales-2017
status : Continuing
aliases : {Шекспир и Хэтавэй: частные сыщики}
banner : /banners/graphical/343179-g.jpg
firstAired : 2018-2-26
id : 343179
image : /banners/posters/5b0a2f7457062.jpg
network : BBC One
overview : Comedy drama about an oddball couple of private detectives who investigate crime in Stratford-upon-Avon
poster : /banners/posters/5b0a2f7457062.jpg
seriesName : Shakespeare & Hathaway: Private Investigators
slug : 343179-show
status : Continuing
#>
function Get-TvDbSeriesName
{
[CmdletBinding()]
[Alias()]
Param
(
[CmdletBinding(DefaultParameterSetName='Search By Name')]
# Name help
[Parameter(Mandatory=$true,
ValueFromPipeline=$true,
Position=0,
ParameterSetName='Search By Name')]
[string[]]$Name,
# Authorization Help
[Parameter(Mandatory=$true,
ValueFromPipelineByPropertyName=$false,
Position=1)]
[System.Collections.Hashtable]$Authorization,
# Exact Match Help
[Parameter(Mandatory=$false,
ValueFromPipelineByPropertyName=$false,
Position=2,
ParameterSetName='Search By Name')]
[switch]$ExactMatch = $false,
# Return all details help
[Parameter(Mandatory=$false,
ValueFromPipelineByPropertyName=$false,
Position=3)]
[switch]$ReturnAllData = $false,
# Name by ID help
[Parameter(Mandatory=$true,
ValueFromPipeline=$true,
Position=0,
ParameterSetName='Search By Id')]
[Alias("Id")]
[int[]]$SeriesId
)
Begin
{
}
Process
{
if ( $PSCmdlet.ParameterSetName -eq "Search By Id" )
{
ForEach ( $id in $SeriesId )
{
$Series = Invoke-RestMethod -Uri ( "https://api.thetvdb.com/series/$( $id )" ) -Headers $Authorization -Method Get
if ( $Series )
{
$ResultSet = $Series.data
if ( -not $ReturnAllData )
{
$ResultSet | Select-Object -Property id, seriesName
}
else
{
$ResultSet
}
}
}
}
else
{
ForEach ( $n in $Name )
{
# Encode Strings because some characters (question marks and ampersands) cause problems in URLs
$n = [System.Web.HttpUtility]::UrlEncode($n)
$Series = Invoke-RestMethod -Uri ( "https://api.thetvdb.com/search/series?name=$( $n )" ) -Headers $Authorization -Method Get
if ( $Series )
{
$ResultSet = $Series.data
if ( $ExactMatch )
{
$ResultSet = $ResultSet | Where-Object { $_.seriesName -eq [System.Web.HttpUtility]::UrlDecode($n) }
Write-Verbose -Message "Found $( $Series.data.Count ) matching series names"
}
if ( -not $ReturnAllData )
{
$ResultSet | Select-Object -Property id, seriesName
}
else
{
$ResultSet
}
}
else
{
Write-Error -Message "No series name found matching '$( $n )'"
}
}
}
}
End
{
}
}
There are two switch flags that I’m optionally passing to this function, $ReturnAllData and $ExactMatch. Sometimes I just want to stop at the series level, but I want more details about it, so I can return all of the data. Most of the time, I’ll want to just get the episodes, so all I need is the series name and the series ID number.
I also elected to add the $ExactMatch for searching because when I went to convert my old DuckTales DVD’s I forgot about the new DuckTales (which is excellent BTW), so I kept getting two returns. It was easier for me to just add the exact match logic to the function than it was to put it in later on.
Lastly, I needed to get information on the episodes, which should be easy for most things in my library. The difficulty came in when shows had incredibly large number of episodes. I don’t have many of these, but I also didn’t want the call to only include first first X results. After reading a little bit, I learned how to use the pagination in the API, so I had to be a little more clever about the logic.
I also added a new member as “episodeNumber” in the form of S##E## because that’s what I’m used to seeing. What I ended up with was this function.
<#
.Synopsis
Get episode informaiton from a specific series
.DESCRIPTION
Makes a call to theTvDb API and returns matches for episodes in one or more series. Requires an authorization header which can be obtained with 'Get-TvDbAuthenticationHeader' and the series ID which can be obtained from 'Get-TvDbSeriesName'
.EXAMPLE
Get-TvDbEpisodeData -SeriesID 311711 -Authorization $Authorization
seriesName seriesId Episodes
---------- -------- --------
The Good Place 311711 {@{id=5648788; airedSeason=1; airedSeasonID=670811; airedEpisodeNumber=1; episodeName=Everything is Fine; firstAired=2016-09-19; guestStars=System.Object[]; direct...
.EXAMPLE
Get-TvDbSeriesName -Name "The Good Place" -Authorization $Authorization -ExactMatch | Get-TvDbEpisodeData -Authorization $Authorization
seriesName seriesId Episodes
---------- -------- --------
The Good Place 311711 {@{id=5648788; airedSeason=1; airedSeasonID=670811; airedEpisodeNumber=1; episodeName=Everything is Fine; firstAired=2016-09-19; guestStars=System.Object[]; direct...
.EXAMPLE
Get-TvDbEpisodeData -SeriesId 75332, 70328 -Authorization $Authorization
seriesName seriesId Episodes
---------- -------- --------
General Hospital 75332 {@{id=168035; airedSeason=30; airedSeasonID=8744; airedEpisodeNumber=1; episodeName=#7649; firstAired=1993-03-31; guestStars=System.Object[]; directors...
The Young and the Restless 70328 {@{id=146; airedSeason=25; airedSeasonID=18; airedEpisodeNumber=1; episodeName=Ep. #6225; firstAired=1997-10-08; guestStars=System.Object[]; directors=...
#>
function Get-TvDbEpisodeData
{
[CmdletBinding()]
[Alias()]
Param
(
# ID numbers for the television series
[Parameter(Mandatory=$true,
ValueFromPipeline=$true,
ValueFromPipelineByPropertyName=$true,
Position=0)]
[Alias("id")]
[int[]]$SeriesId,
# Authorization Header - Required
[Parameter(Mandatory=$true,
ValueFromPipelineByPropertyName=$false,
Position=1)]
[System.Collections.Hashtable]$Authorization
)
Begin
{
}
Process
{
ForEach ( $s in $SeriesId )
{
$EpisodesData = @()
$Next = 1
do
{
$Episodes = Invoke-RestMethod -Uri ( "https://api.thetvdb.com/series/$s/episodes?page=$Next" ) -ContentType "application/json" -Headers $Authorization -Method Get
$EpisodesData += $Episodes.Data
$Next = $Episodes.links.next
} while ( $Episodes.links.next )
$EpisodesData | Add-Member -MemberType ScriptProperty -Name "episodeNumber" -Value { "S$( $this.airedSeason.ToString("00") )E$( ( $this.airedEpisodeNumber ).ToString("00") )" } -Force
New-Object -TypeName PSObject -Property ( [ordered]@{ "seriesName" = Get-TvDbSeriesName -SeriesId $s -Authorization $Authorization | Select-Object -ExpandProperty seriesName;
"seriesId" = $s;
"Episodes" = $EpisodesData } )
}
}
End
{
}
}
So if I wanted to find all the episodes of Futurama, I would do the following:
$Header = Get-TvDbAuthenticationHeader -ApiKey $ApiKey -UserKey $UserKey -Username $Username
$Series = Get-TvDbSeriesName -Name "Futurama" -ExactMatch -Authorization $Header
$Series.Id
$Episodes = Get-TvDbEpisodeData -SeriesId $Series.Id -Authorization $Header
$Episodes.Episodes | Select-Object -Property episodeNumber, episodeName
Needless to say there are many things I can do to extend and improve on this, but I realized that working with APIs will be the future of much of the work I do in scripting in the future and this was a fun little project to get me more comfortable working with varying datasets and the API calls.
I’m currently using these scripts to do lookups for episodes based on number and then rename the files based on the episode name. Like I said at the beginning, it’s easier to find “Jurassic Bark” than remembering which season and episode number when I need a good cleansing cry.
For anyone who reads this, what do I need to do next for this set of scripts? Download the posters to a folder? Add in an actor or character search? There’s much to do, but I don’t know where to start.
Until next time, ramblers.
Thanks a lot, these were very helpful.
This is great! Let’s connect, I recently wrote (neatly) a mega script that automates “HandbrakeCLI” which would work nicely hand and hand with what you have shared here. Imagine, getting TV series data, and then automating the conversion of a DVD series to digital, all while being tagged and organized for immediate upload to your favorite media server platform.
This sounds like something lovely to work on.
TheTvDb recently changed their authentication mechanism, so I had to re-write a little of the above code. I haven’t had a chance to fully test it and re-publish it, but I will shortly. I also plan on putting it all into GitHub so people can collaborate if they want.