When Managing Toddler Screen Time Requires Reverse Engineering

Imagining what attributes a child-oriented streaming service could have (if you're eccentric)

When Managing Toddler Screen Time Requires Reverse Engineering
A DALL-E render of what should happen to streaming devices

Part of my on-going "doing strange things to wrestle back control of technology" series.


Author's note: When I write up a guide like this it may sometimes be more free-form than one is used to seeing on a tech blog. If I initially made incorrect assumptions or mistakes I leave those in the guide chronologically rather than portray a fictionalized or idealized series of steps. I think sometimes those wrong paths can have interesting lessons in themselves.

Motivation

There is no more user hostile product online than streaming services, especially in the context of managing the screen time of children.

All streaming services are intentionally designed to be as aggravating as possible, while keeping you (or more importantly your children) engaged, trudging endlessly through content carousels. We're so used to this state of affairs I don't think many people give it much consideration.

If you were to imagine from scratch what attributes a child-oriented streaming service would or should have you might think of the following:

  • An allowlist by default vs. a blocklist: Blocklists are a feature I am only aware of on Netflix (I know Disney does not have one), though you have to login to the site directly in a browser to access it, and you have to enter one show at a time. I should not have to manually remove dozens of programs from my child's profile individually.
  • Timers: You should be able to set timeouts with various fun, granular options for scheduling blackouts and whatnot.
  • More than one watchlist: e.g., like song playlists.
  • Age blocks: These are at least available universally
  • No ads, suggestions, nudges, etc. in children's profiles

The most control I've managed to exert over our daughter's streaming setup is to make one curated watchlist on each app, and single out Cocomelon for being entirely struck from Netflix. Even so, to get to the watchlist I have to march past Disney's latest content offerings first, i.e., 😲 "what's that one??"

Is there a better way?

The better way

💡
Disclaimer: I am only familiar with the Roku ecosystem. Maybe other streaming hardware is better, but I seriously doubt it.

Initial Proposal

I originally had the idea of creating some kind of physical show and movie "tokens" my three year old daughter could use to choose her own shows in a responsible and controlled manner.

Roku did not make this straightforward.

Ideally, this system would be smart enough to play only one show or movie at a time, but this proved to be infeasible for several reasons:

  • My original draft idea was to create a fixed barcode scanner driven setup to scan QR codes linked to content and start shows and movies. I eventually ruled this out because I don't like lasers, even low powered barcode scanners, being used around toddlers, and rigging up a Raspberry Pi and webcam for image recognition was too much added work. I did keep the QR code concept in part.
  • There is no way to randomly choose an episode.
💡
Editor's note: This is technically incorrect and is revised below.
  • There is no way to guarantee a show or movie will play from the beginning if it was already started once before. This is exceedingly annoying, and something I personally can't stand on my own viewings. In my opinion, shows and movies should always start from the beginning, and have a button to pick back up at the last position. Without this, coding my own automatic screen time limits per episode would not be not reliable.

Revised Proposal

Scaling back my original ambitions I settled on creating QR code cards that will directly play a show or movie on the correct service when scanned by your phone. This is still a major improvement in that the watchlist has now become only what is physically present, and by exchanging a card to be scanned and then shelved the illusion that streaming is something limitless is eroded a little.

Even this was difficult.

Exploring Roku APIs

So what can you do with them?

I spent an afternoon pouring over various Roku API documentation, and it became clear they also are not very invested in you having a lot of control over how you consume content. Less so than the streaming companies, likely because Roku is here for selling your data to advertisers, and probably isn't that invested in which particular channel you're watching, but still generally somewhat hostile.

Roku devices all run a local REST API you can easily access, and for many things this is what the mobile app is using. The External Control Protocol (ECP) is charming, but basically doesn't expose any different functionality than just using the remote. I experimented with chaining together commands to use the on-screen Roku-side search to play a show or movie, but for some reason using ECP to access the on-device search was, at least on my TCL TV model, completely broken.

It turns out Roku's Deep Linking API is what I was after, there was just one minor roadblock.

The Deep Linking API, while technically accessible to everyone, seems to be mainly intended for Roku channel developers, and the contentId of a show or movie (which is what uniquely identifies a program) is more or less "secret," or perhaps more charitably "undocumented." The API documentation presumes you know your own program's contentId and therefore does not expose it in any other API methods. I found very few references on this subject online, aside from the helpful observations by a few people that both Hulu and YouTube just use obvious URL parameters you can easily discover by just watching something within a browser.

Disney Plus (which is identified on Rokus as channel 291097) was the channel I was focusing on first, and I was initially confused by their most probable contentId scheme, as the GUID in the URL was the most obvious choice, and it did not work the first round of testing I did.

URL for the Bluey series page

Both of these examples simply pulled up the Disney Plus landing page.

curl -X POST "http://[roku_ip_address]:8060/launch/291097?contentId=entity-fa6973b9-e7cf-49fb-81a2-d4908e4bf694"

curl -X POST "http://[roku_ip_address]:8060/launch/291097?contentId=fa6973b9-e7cf-49fb-81a2-d4908e4bf694"

I reasoned though that the mobile app must be using this API for kicking off a show when selecting the Disney Plus option in the in-app search, so I setup a packet trace logger on my phone and fired off an episode of Bluey.

Animated. Children. Entertainment.

Glancing at one of the packets I then sighed.

GET /ecp-session HTTP/1.1
Sec-WebSocket-Origin: Android
Sec-WebSocket-Protocol: ecp-2
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: permessage-deflate
Host: xxx.xxx.xx.xxx:8060
Accept-Encoding: gzip
User-Agent: okhttp/4.9.0-roku1

It's using encrypted WebSockets for local requests. I conceptually understand WebSockets but was not experienced in debugging them, or network packets in general to be honest, much less manually figuring out the key exchange, but I decided to fire up Wireshark for the first time in a decade and see if this is something it can seamlessly parse.

It turns out it can. I selected the "Unmasked data" button and I was greeted with the following request:

{
  "request": "launch",
  "request-id": "6",
  "param-params": 
    "{ \"action\": \"display\",
       \"contentid\": \"133b4d69-2863-4713-a6a4-62012caf4759\",
       \"mediatype\": \"series\",
       \"gc_run_source\": \"roku-mobile-app\",
       \"gc_run_source_id\": \"mobile-search\",
       \"gc_run_source_session_id\": \"...\"
     }",
  "param-channel-id": "291097"
}

The decrypted Websocket request

I first I thought this was the GUID for Bluey, as the mediaType was labeled as "series" and Bluey started playing immediately, so I proclaimed success, wrote and published the first draft of this post, and went to bed.

curl -X POST "http://[roku_ip_address]:8060/launch/291097?contentId=133b4d69-2863-4713-a6a4-62012caf4759"

Attempt #1

Day 2

Assumptions were hasty

I have an unfortunate habit of making hasty assumptions, and decided to Google that GUID the next morning, as surely some tired tech parent had to have done this before me with Bluey, and I was curious what I would find.

RIP

The GUID 133b4d69-2863-4713-a6a4-62012caf4759 does not actually belong to Bluey, it belongs to Season 1 Episode 52, "Verandah Santa," which is in fact what played the day prior.

Feeling like Verandah Santa

Where I got tripped up is that on my TCL Roku TV, besides the packet data above being a bit misleading, the mediaType also did not seem especially important at the time, and the Attempt #1 cURL example above did indeed start playing a Bluey episode so I assumed it worked as expected. This transaction however does not work on my Roku Streaming Stick and drops you back into the Disney landing screen. When you add mediaType=episode it now works as expected on both devices, if Verandah Santa is what you're after.

curl -X POST "http://[roku_ip_address]:8060/launch/291097?contentId=133b4d69-2863-4713-a6a4-62012caf4759&mediaType=episode"

Attempt #2

When I did the initial packet trace I did also try to setup a MITM as well, but the Roku app used certificate pinning and would not function until I turned it off, so I could not see any intermediate request that would have indicated what the GUID was of Bluey itself. My guess is that the app pulled down the contentId of the next episode of Bluey from an external server, and then that is what I captured later when it was sent down to the Roku.

Now that I realized that perhaps the mediaType is not so optional after all, and knowing that the GUIDs for episodes are not actually obfuscated on Disney dot com I revisited the URL for the series page, dropped the entity- prefix, and this time added mediaType=series to the request.

curl -X POST "http://[roku_ip_address]:8060/launch/291097?contentId=fa6973b9-e7cf-49fb-81a2-d4908e4bf694&mediaType=series"

Attempt #3

Actual success. To directly play a show skipping every menu and layer, you simply need to POST to the Roku's API with the parameters above.

Next I moved on to making a physical show card. To my knowledge there is no way to indicate POST vs. GET from a QR code, so I did still need to create a shim of some kind to tie everything together. I made a very basic Flask script to send a GET to via a QR code, and which then generates the POST to the Roku's API.

from flask import Flask
import requests

app = Flask(__name__)

@app.route("/")
def index():
    return "Roku listener alive"

@app.route("/bluey")
def bluey():
    x = requests.post("http://[roku_ip_address]:8060/launch/291097?contentId=fa6973b9-e7cf-49fb-81a2-d4908e4bf694&mediatype=series")
    return "Command sent"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=True)

Sample POST'ing server

From there it's just a matter of printing and laminating.

Final product

And it works! Simply scanning the QR code with your phone automatically begins playing an episode of Bluey on the living room TV.

Now that we know that the GUIDs in the URL strings actually do point to individual episodes it is trivial to scrape a list of them from the show page and build a local catalog. This is nice as well because if there are particular episodes you'd like to avoid you can exclude them.

<a
  role="link"
  data-item-id="c578bc71-4814-492c-bd40-dbbd5e31a45a"
  class="_2c2l371 _11x60sa1 xgfbc1xq xgfbc117a xgfbc11gu xgfbc11qe xgfbc141i xgfbc1c5 xgfbc1d2"
  data-testid="set-item"
  aria-label="Season 1 Episode 43 Camping. 100 percent complete"
  aria-disabled="false"
  tabindex="0"
  href="/play/c578bc71-4814-492c-bd40-dbbd5e31a45a"
>

As a Phase 2 I will gather a list of episodes similar to Mr. ragingcomputer from Github earlier, and extend the Flask script to randomly choose a recently un-watched episode, maintain a log of the past viewings, print more cards, and start utilizing the chore timer for pacing TV time.

I thought it would also be cute to purchase some cassette tape jewel cases and make mini VHS style covers with the QR codes inside that can sit on the shelf and recapture the vibe of watching movies 20 years ago. That project will be an upcoming post.

Happy hacking.


Post Revision Log

I often make mistakes. As they come to my attention I will post revisions.

  • 2024-08-19: I initially thought the GUID 133b4d69-2863-4713-a6a4-62012caf4759 belonged to Bluey the show since that was what I selected on the remote app. It actually belongs to Season 1 Episode 52, "Verandah Santa." I've revised the post.
  • 2024-08-21: Minor edits

Subscribe to wjbolles macroblog

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe