Building a Voice-Driven TV Remote - Part 5: Adding a Listings Search Command

This is part five of the Building a Voice-Driven TV Remote series:

  1. Getting The Data
  2. Adding Search
  3. The Device API
  4. Some Basic Alexa Commands
  5. Adding a Listings Search Command
  6. Starting to Migrate from HTTP to MQTT
  7. Finishing the Migration from HTTP to MQTT
  8. Tracking Performance with Application Insights

In the previous post I added some basic remote control command functionality, allowing for being able to do things like mute, pause and resume playback, and change the volume. Earlier in the series I talked through building a search engine for TV listings so that I could change channels based on the name of a show or movie, rather than having to know the exact channel. In this post I'll show how to extend the Alexa skill in the last post to include this feature.

Commands

In the last post createda separate module for handling remote commands, with its definition living inside of a file named commands.fsx. Since now we're going to be making a few more types of requests, all of which need to include the same authorization header and URL structure, I'll first DRY things up a bit in the previous implementation:

type CommandsResponse = JsonProvider<""" {"commands":[{"name":"VolumeDown","slug":"volume-down","label":"Volume Down"}]} """>
type StatusResponse = JsonProvider<""" {"off":false,"current_activity":{"id":"22754325","slug":"watch-tv","label":"Watch TV","isAVActivity":true}} """>

let private makeRequest method urlPath =
    let url = sprintf "%s/%s" (Environment.GetEnvironmentVariable("HarmonyApiUrlBase")) urlPath
    let authHeader = "Authorization", (Environment.GetEnvironmentVariable("HarmonyApiKey"))

    Http.RequestString(url, httpMethod = method, headers = [authHeader])

let getCommand (label: string) =
    makeRequest "GET" "commands"
    |> CommandsResponse.Parse
    |> fun res -> res.Commands
    |> Seq.tryFind (fun command -> command.Label.ToLowerInvariant() = label.ToLowerInvariant())

let executeCommand commandSlug = sprintf "commands/%s" commandSlug |> makeRequest "POST" |> ignore

The Azure Functions team quickly jumped on the issues I'd mentioned previously preventing me from using the JSON type provider, so that now works great and is used here to keep things tidy.

With that cleaned up, now I'll need a couple new commands. First, one to turn on the "Watch TV" activity if it's not already active:

let watchTV() = 
    makeRequest "GET" "status" 
    |> StatusResponse.Parse 
    |> fun res -> match res.CurrentActivity.Slug with
                  | "watch-tv" -> ()
                  | _ ->
                    makeRequest "POST" "activities/watch-tv" |> ignore
                    Async.Sleep(15*1000) |> Async.RunSynchronously

If the activity is already active it does nothing, and otherwise starts the Watch TV activity and pauses for 15 seconds. The pause is there to account for the time it takes for the activity to become fully active and make its commands available to call. This timeout actually currently results in the Alexa skill timing out so the user feedback isn't great, but it does the job for now. In a future version I'll likely separate the commands into a separate queue-driven function or something along those lines, in order to allow the user-facing skill to be more performant.

Next, I need a way to tune to a particular channel number:

let changeChannel number = 
    string number |> Seq.map string |> Seq.iter executeCommand
    executeCommand "select"

Each number is exposed as an individual command by the activity, so this simply splits up the number into its digits and relays those as commands, closing with a "select" command to immediately tune to the channel.

Search

Now it's time to start hooking into the listing search engine. I'll do that in a new module named Search, defined in search.fsx, and start by creating a generic search function and then versions for searching the indexes for channels and shows:

module Search

open System
open FSharp.Data
open FSharp.Data.HttpRequestHeaders

type SearchRequest = JsonProvider<""" {"filter": "ChannelId eq 125", "top": 100, "search": "seinfeld"} """>
type ShowSearchResults = JsonProvider<""" {"value": [{"@search.score": 2.5086286, "ShowId": "1046", "Title": "Seinfeld", 
                                                      "StartTime": "20170102200000", "EndTime": "20170102233000", "ChannelId": 125,
                                                      "Description": "Yada yada yada", "Category": "Sitcom" }]} """>
type ChannelSearchResults = JsonProvider<""" {"value": [{"@search.score": 1, "ChannelId": "150", "XmlTvId": "foo.stations.xmltv.tvmedia.ca",
                                                         "DisplayName": "CBSSN-HD", "FullName": "CBS Sports Network USA HD", "Number": 123}]} """>

let private search index (request: SearchRequest.Root) =
    let url = sprintf "%s/indexes('%s')/docs/search?api-version=2015-02-28" (Environment.GetEnvironmentVariable("SearchUrlBase")) index
    let apiKeyHeader = "api-key", (Environment.GetEnvironmentVariable("SearchApiKey"))
    let json = request.JsonValue.ToString()

    Http.RequestString(url, body = TextRequest json,
                       headers = [ ContentType HttpContentTypes.Json; apiKeyHeader ])

let private searchShows request = search "shows" request |> ShowSearchResults.Parse
let private searchChannels request = search "channels" request |> ChannelSearchResults.Parse

Now I can expose a method to find a channel by its ID:

let findChannel channelId = 
    SearchRequest.Root((sprintf "ChannelId eq '%i'" channelId), 1, "") 
    |> searchChannels
    |> fun result -> 
        match Array.tryPick Some result.Value with
        | Some(channel) -> Some(channel.Number)
        | None -> None

I'll also provide a method that searches for a show based on what the user sends in, filtered to shows that are currently airing at that moment:

let findShowOnNow (name: string) =
    let timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss")
    let filter = sprintf "StartTime lt '%s' and EndTime gt '%s'" timestamp timestamp
    
    SearchRequest.Root(filter, 1, name) 
    |> searchShows
    |> fun result -> Array.tryPick Some result.Value

Updating the Skill

Finally, we need to update the actual skill implementation to support all of this.

Interaction Model

Before updating the code we'll need to update the skill's interaction model to define the new intent. In the intent schema, add in an entry for a new intent named WatchShow:

{
  "intent": "WatchShow",
  "slots": [
    {
      "name": "name",
      "type": "show_name"
    }
  ]
}

This intent uses a new custom slot type named show_name, which I tentatively identified as:

seinfeld
rangers
new york rangers
game of thrones
indiana jones and the last crusade

This doesn't result in a hardcoded list of values for the slot, but instead helps Amazon determine the types of patterns to expect.

Lastly, add a few new sample utterances to the model as well:

WatchShow turn on {name}
WatchShow watch {name}
WatchShow switch to {name}

This will enable commands such as Alexa, tell the TV to turn on Seinfeld or `Alexa, ask the TV to watch the Rangers game".

Function Definition

We're almost there! Now the skill just needs a little more code to hook into the functions we defined earlier. First, in run.fsx make sure to pull in the Search module:

#load "search.fsx"

Similar to the previous method for handling the DirectCommand intent, define a function for handling the new WatchShow intent:

let handleWatchShow (intent: Intent) =
    Search.findShowOnNow intent.Slots.["name"].Value |> function
    | Some(show) -> Search.findChannel show.ChannelId |> function
                    | Some(channel) -> 
                        Commands.watchTV()
                        Commands.changeChannel channel
                        
                        buildResponse "OK" true
                    | None -> buildResponse "Sorry, I could not find the channel for that show" true
    | None -> buildResponse "Sorry, I could not find that show" true

If it can't find the show it provides that as spoken feedback to the user. The same goes for the channel, though if everything is working that one should never happen. I also suspect that down the line I'll also update the listings schema to include the channel ID directly instead of having to initiative a second lookup every time, but I'll leave things as-is for now.

Now we just need to add one line to the handleIntent function for the new intent:

let handleIntent (intent: Intent) =
    match intent.Name with
    | "DirectCommand" -> handleDirectCommand intent
    | "WatchShow" -> handleWatchShow intent
    | _ -> buildResponse "Sorry, I'm not sure how to do that" true

Now our remote skill has been updated to be able to change channels based on the name of a show or movie! This is admittedly a super basic implementation that I'll definitely be improving a bit as I go. One thing I'll likely add is an extra filter on the search score returned with results in order to reject ones that don't meet a certain threshold and avoiding returning poor results that just happen to contain some of the words you searched or.

Either way, for now the fact that this works at all is pretty darn satisfying!


Next post in series: Starting to Migrate from HTTP to MQTT

comments powered by Disqus
Navigation