Building My Own Streaming TV Station

Navigate to:

Recently a colleague mentioned having seen a YouTuber set up their own cable TV channel at home.

This struck me as quite a good idea: I sometimes want to have the TV on in the background (the background noise helps the dogs settle) but often struggle to pick something from the myriad of available options (which include a ripped copy of most of our DVDs).

Live TV and Prime Video aren’t really viable options: the aim is to have some background entertainment, not to get distracted by annoying ads for stuff that I’m never going to buy.

Having an ad-free channel that only plays stuff that I like would make it a lot easier to just slap something on; it’s not like I’m usually intending to watch it.

We’ve got a range of devices in the house, so I wanted my implementation to use commonly supported protocols (HLS and/or RTMP) and, ideally, to be able to run on my Kubernetes cluster.

In this post, I’ll talk about some of the more interesting aspects of the container image that I built in order to run my own streaming TV station using my collection of ripped DVDs.

Overview

The system is simply a docker container which randomly selects media from a local library, making it available to players via RTMP and HLS.

Just like a TV channel, it plays its own schedule.

Inevitably, I got a little carried away, so the feature set also includes

  • Allow and Block lists for media: constraining what the channel can stream
  • Ability to set a broadcast window: constraining when the channel can stream
  • Dynamic broadcast triggering: reducing use of compute if no one’s actually watching
  • Writing play history and other stats into InfluxDB
  • An API endpoint to skip to the next episode (in case of playback issues or “I’ve seen this”)

I chose to use InfluxDB rather than (for example) writing a textual log because writing into a time-series database allows easier exploration and analysis of the play history. It also allows the history to easily be correlated against [performance and usage stats] (for example, to identify whether certain TV series tend to result in an increased frame-drop rate).

Other InfluxDB features (like support for bucket retention periods also allow easy management of that data’s life cycle (for example, by expiring stuff out when it’s old enough that it’s not likely to be of interest).

Running

Invocation via docker is simple:

Inevitably, the YAML to run on Kubernetes is a little more verbose but still pretty simple as K8s config goes:

YAMAL

RTMP & HLS

Thanks to some of my previous jobs, I’ve got quite a bit of prior experience with video delivery, including RTMP.

In fact, in a previous role, I owned and operated a private fork of nginx-rtmp, that had to be heavily customized (in other words, I did some unspeakable things to it) in pursuit of extremely diverse customer needs.

This experience is relevant because one of the things that you get from owning, operating and supporting a custom RTMP application (particularly at CDN scale), is the wisdom to know that it’s something that you only ever want to do once (if that…).

So, although nginx-rtmp sits at the core of this build, I’ve not customized it at all and have instead built tooling around it.

Although I’ve built HLS tooling in the past, there was no need for that this time: nginx-rtmp has HLS support built into it (it supports DASH too, but I’m not currently using it).

Video Processing

The project involves reading in arbitrary video files, so, obviously, I reached for the Swiss army knife of video conversion: FFmpeg

Using ffmpeg to stream files to a RTMP server is a well-solved problem:

image 2 The crucial flag in this command is -re , it instructs ffmpeg to read the file at its native frame rate, ensuring that the file is streamed in real time rather than all at once.

Publishing Script

I wrote the automated publishing in BASH.

At the job with the RTMP stack, I had a co-worker who would occasionally jokingly leave a code review comment on shell scripts: “Line count suggests this should have had a .py extension instead.”

I imagine that the publishing script would attract a similar review, and…. he’d not be wrong. I started to regret choosing BASH at about the time that I realised I needed to iterate through lines in a file and then do the equivalent of '|'.join() on them.

But, I was already invested… Maybe I’ll rewrite it at some point.

Video Selection

The publishing script chooses a random series followed by a random episode, filtering each with a regular expression constructed from the blocklist.

The filter is applied at both stages so that I can include filters that target episodes as well as series.

For example, if I wanted to ensure that specials aren’t included, I might filter out any file containing the string s00e.

Dynamic Publishing Trigger

The first build of the container was pretty simple:

  • Launch nginx-rtmp
  • Loop, publishing random episodes into nginx-rtmp

The result was an IPTV channel that was always on.

But…. It was always on. Even if I wasn’t watching, ffmpeg was dutifully whizzing away, tying up precious cores and sapping electricity:

image 3

So, I decided to change it so that the publishing script only starts a new episode if a player is connected.

The nginx-rtmp module has a range of event hooks, including a play event. Each hook places a simple HTTP request to an arbitrary endpoint.

So, with a single line of config:

Nginx sends details of the play request onto the destination:

image 4

I cobbled together a small control server (in Python—I wasn’t repeating the mistake I made with the entry point), allowing for a somewhat janky but functional flow:

  • Player connects to nginx-rtmp
  • nginx-rtmp sends play event to control server
  • Control server writes play (and player count) to control files
  • Publishing script detects a change in state and starts ffmpeg
  • Playback starts

There’s a similar workflow if a player later disconnects:

  • Player disconnects
  • nginx-rtmp sends play_done event to control server
  • Control server subtracts 1 from player count
  • If player count is < 1, writes stop state
  • ffmpeg continues current stream but does not start next while state is stop

I decided not to stop ffmpeg on disconnect because I might want to reconnect (perhaps as a result of changing rooms).

In the logs, it looks like this:

Technically, this process extends video startup times, but to help ensure smooth playback, RTMP players tend to sit and fill a buffer first, so the additional delay is not all that noticeable.

Introducing this delivered a couple of benefits, including:

  • CPU & electricity aren’t wasted processing something that no one is watching
  • I don’t miss the beginning of episodes because the system won’t start the stream til someone’s watching

The one real drawback is that it only works with RTMP streaming. HLS is served over a standard HTTP connection, so there’s no simple equivalent (there are ways around it, but they’ve all got tradeoffs that I didn’t want to have to make).

Broadcast Window

If I’m honest, implementing this was much more about nostalgia than efficiency.

The broadcast window states that the system should only broadcast video between set hours:

Outside of those times, rather than starting a new episode, players will receive an old BBC test card.

image 5

It does differ a little from the original, though, in that the card is streamed in silence. Nostalgia only goes so far, and I didn’t really fancy reliving the experience of being woken by the beep kicking in.

Avoiding Freezes Between Episodes

The system’s design means that ffmpeg has to consume a range of arbitrary video files. Having been ripped over the course of quite a few years, there’s quite a lot of variation between them, including:

  • Video and audio codecs
  • Framerate
  • Resolution
  • Pixel Format (rarer, but still)

nginx-rtmp pretty much just forwards what’s being piped into it, so when an episode changes, the downstream player might end up receiving frames that are completely different to those that came before.

Some players (like ffplay) handle this ok. Most, though, end up freezing in response.

To avoid these unexpected changes, I adjusted the ffmpeg invocation so that it would normalize it’s output:

In order to ensure a common resolution, a scale filter is used to target 720p. If the input file has a lower resolution, then letterboxing is used.

With that change made, the transition between episodes became a lot more reliable.

I also implemented an optional mechanism that could be used to redirect players away from the stream (and back again) at episode change-over. However, Kodi’s Simple IPTV Player plugin doesn’t seem to like that very much.

Play History and Stats

We’ve amassed a fair collection of episodes over the years, enough that I sometimes may not recognise what we’re watching (or perhaps I’m just getting old).

Originally, I implemented a small API endpoint which could be called to check what’s currently playing.

But what if I’m absorbed and forget to check until after it’s finished? Then I’ll never know what that great show was!

Instead, I decided to have the container write a play history into InfluxDB.

image 6

Conclusion

At its heart, this was just a fun project to play around with and keep myself out of trouble for a couple of days.

But, it also meets its target use case quite well, even if it doesn’t always succeed in just being in the background: the other evening I sat down, flicked it on and was greeted by an episode of Bucky O’Hare!

Best of all, there are no ad breaks. Admittedly, I did consider adding one so that I could sue Musk for “witholding billlions of dollars in advertising revenue” (that’s how it works now, right?).

The pod’s resource demands vary a bit depending on the episode being streamed: processing higher resolution videos obviously demands more resources than lower res ones. I’ve currently got the pod capped at 2 cores and that’s been fine for the majority of media.

Not that I’m going to be adding that many players, but the majority of the compute expense is in the initial publishing—the actual stream delivery is incredibly cheap (I’d likely hit network contention before CPU became an issue on that front).