IPTV: Plugging Live TV Into a Server That Was Built for Files

Open the Channels tab. You used to see a handful of simulated-live channels stitched together from movies and TV episodes on your disk, the kind of thing that makes a rainy Sunday afternoon feel like cable from 1994. This week, you see something new. A row of poster-style tiles sits above the local channels, each one the name of a TV service you happen to pay for. You tap one, and it opens into a wall of pill-shaped categories: Sports. News. Kids. Weather. Local. Tap a category, and a grid of channel logos slides in. Tap a logo, and the stream plays in the same player that plays the DVDs you ripped a decade ago.

What just happened is that a self-hosted media server with a hard boundary around "your local library" learned to speak live television. It is the kind of feature that sounds easy in a planning meeting. You receive a URL from your provider, you hand it to a player, the player plays it. How hard can it be?

Hard. Turns out.

This is the story of threading IPTV into ShowShark without poisoning any of the assumptions the server was built on, without leaking provider credentials to the client, and without setting the whole place on fire when a live playlist started lying to us every thirty seconds.

What An IPTV Provider Actually Hands You

Two formats have become standard in the industry. Almost every provider supports one, most support both.

The first is an M3U playlist. It is a plain text file, the same format your grandparents' MP3 player used, with one line per channel and a bunch of metadata sprinkled on top. It looks like this:

#EXTM3U
#EXTINF:-1 tvg-id="bbc1.uk" tvg-name="BBC One" tvg-logo="https://..." group-title="UK",BBC One
http://provider.example:8080/live/username/password/12345.m3u8

Every channel has a URL, a display name, a logo, and a bunch of optional fields that nobody implements consistently. Some providers inline your username and password directly into the stream URL, which is a charming 1998-era approach to authentication that we will revisit later. Some put them in the playlist URL itself. Some use neither and expect the user to pass them as HTTP headers. It is the Wild West in there.

The second is the Xtream Codes API. Despite the name sounding vaguely like an activation code for a video game, Xtream Codes is a real REST-ish API that most paid IPTV services have converged on. You authenticate once, and the API hands you categorized lists of channels, a lightweight EPG (electronic program guide) for each channel, and URLs you can play. The category tree is richer than M3U's "group-title" field, and the EPG actually works, so when we can detect an Xtream endpoint we prefer it.

Either way, what comes out the far side is the same shape of data: a list of channels, grouped into categories, each with a name, a logo, and a URL that will serve up live video when you connect to it.

The Architectural Decision, Part 1: IPTV Is Not A Location

ShowShark has a concept called Locations. A Location is a place where your files live. You can point it at a folder on the Mac's local disk, at an external drive, at an S3 bucket, or at an SMB share mounted off a NAS. Every Location satisfies the same protocol: you can list its directory contents, you can ask how big a file is, and you can read a byte range out of a file.

Looking at IPTV from orbit, the first reaction is "this is a new Location type." Providers list their channels; channels stream their bytes; surely the right move is to extend the protocol and wire in a new backend. A clean layered architecture, orthogonal and elegant, etc.

Fifteen minutes of staring at the code killed that idea.

Live streams have no directory tree to enumerate. There is nothing to listDirectory on. A channel has no meaningful size, so fileSize would have to return a sentinel or throw, and every caller would have to learn a new special case. There are no bytes to range-read, because live TV is not a file, it is a river; you cannot ask for "byte 2048 through 4095 of CNN," because CNN does not work that way. You just jump into the river and start watching whatever is flowing past.

The library scanner, which walks every Location nightly to update the catalog, would have to grow a "never scan this one, it has nothing to find" flag. Every fallback code path that assumes "if this file exists on disk I can do X" would have to branch on a new "actually this is a live stream" condition. The orthogonality collapses into a thicket of exceptions.

IPTV is a sibling concept to Locations, not a new one. It has its own config store, its own database tables, and its own playback dispatch path. The code paths never cross.

The Architectural Decision, Part 2: IPTV Is Not A Channel, Either

ShowShark also has a concept called Channels. A Channel, in our model, is a simulated live TV station built out of your own media. You can set up one called "Sitcom Night" that alternates between Cheers and Frasier on a schedule, and if you tune into it at 8:42 PM you catch whichever episode is thirty-two minutes into its hour. It is the closest thing to a TV guide the app has ever had.

It would be tempting to shoehorn IPTV into that model. Both have channels. Both have something that looks like "now playing." They are practically the same thing, right?

Wrong again. Simulated-live Channels are made of scheduled files with known durations, known resume points, and known file paths on disk. The scheduler is a wall-clock-to-media mapper: "at 8:42 PM on Tuesday, play minute 32 of Frasier-s03e04.mkv." Every piece of the apparatus assumes durations exist and files can be reopened.

Live IPTV has none of that. No duration. No resume point. No schedule to be mapped. Forcing live channels into the simulated-live model means sprinkling "if this is a real live stream, skip the scheduler" branches across a lot of code that currently has no idea real live streams exist. Again: orthogonality collapses.

IPTV gets its own tables, its own resolver, and its own playback path. The Channels tab in the client will merge the two for display, because that is where the user expects them both to live, but they do not share a single byte of logic on the server.

The Actual Template: Movie Trailers

So what is the model? The answer came from a feature we shipped a few releases ago: movie trailers. When you tap the clapper icon on a movie's detail page, ShowShark plays the YouTube trailer through the same pipeline as a local file. The client never sees a YouTube URL. It sends a playback request with a path that looks like:

trailer:37219

The server sees the trailer: prefix, strips it off, treats 37219 as a movie ID, resolves the YouTube video ID from the database, pulls down the actual CDN URL using a bundled yt-dlp, and fires up the GStreamer pipeline with that resolved URL. The client sits there and streams transcoded frames like nothing unusual is happening.

IPTV follows the same pattern:

iptv:44

Channel 44 is a row in the iptv_channels table. The row knows which provider it belongs to. The provider knows its base URL and how to authenticate. At playback time the server reconstructs the full stream URL, injects any provider-specific headers, and hands the whole thing to the existing HTTP streaming path in the pipeline. The client never sees the provider's hostname. The client does not see the username. The client does not see an HLS manifest URL, a segment URL, or a TLS handshake with any third party.

From the client's perspective, there is only ever one network connection: the WebSocket back to the ShowShark server. The fact that the server is negotiating with some upstream provider to get those bytes is an implementation detail the client was never supposed to know about, and is a nice security property besides. Anyone sniffing the client's network can tell the phone is watching something from the ShowShark server at home; they cannot tell it is your IPTV sports package or that you watch a lot of a specific news network.

This model also means client apps stay trivial. The iOS client does not link a single byte of HLS code. Neither does the tvOS app, nor the visionOS app. All the hard streaming logic stays on the Mac, where we already have GStreamer.

The Decode Pipeline Grew A New Arm

ShowShark's server transcodes media through a GStreamer pipeline. For local files, that pipeline begins with a filesrc element that reads bytes off disk:

  Local file (old path):

  filesrc → matroskademux ─┬─► video branch → raw_videosink ─┐
                           │                                  ├─► encoder → WebSocket
                           └─► audio branch → raw_audiosink ─┘

For IPTV, the source is different. Instead of a local file, we fetch an HLS playlist over HTTPS and let an HLS demuxer handle the segment-by-segment download:

  IPTV (new path):

  reqwesthttpsrc ─► hlsdemux ─► parsebin ─┬─► video branch → raw_videosink ─┐
                                          │                                  ├─► encoder → WebSocket
                                          └─► audio branch → raw_audiosink ─┘

The contract between decode and encode has always been that pair of appsinks at the center of the diagram. The encode side does not care where the samples are coming from. That is what made this expansion cheap in theory: we just needed a new decode-side source.

In practice, we needed two more things.

First, the HTTPStreamingConfig.hlsManifest case existed in the pipeline builder but literally threw PipelineBuilderError.notImplemented. It had been a placeholder since the dual-container work for trailers. Now it had to actually build a pipeline.

Second, the pipeline needed to know about live streams. For a regular file, the end of the file is the end of the session. The demuxer emits an EOS (end-of-stream) signal, the encoder drains, the client sees "playback complete," everybody goes home. For a live stream there is no end. The EOS signal will never come. If any of our existing EOS-handling code fires on a live stream, the session mysteriously ends ten minutes in for no visible reason.

So DecodePipeline now takes an isLive: Bool flag. When it is set, the various end-of-stream watchdogs stand down. Buffer sizes on the appsinks go up, because live streams occasionally switch variants mid-playback and the transient discontinuity needs to be absorbed somewhere. The distribution loop keeps running forever, or until the user presses Stop, whichever comes first.

That was the happy path. Implementing it took about a day.

The sad path took another three.

The Bug Where The Video Kept Going Back In Time

A tester fired up a channel from a paid IPTV provider. The video played for thirty seconds. Then it jumped backward about twenty seconds. It played forward again, jumped back again, played forward, jumped back. The audio came along for the ride. The player was not buffering or stalling; the clock on the bottom of the screen was climbing just fine. It just kept re-playing content from twenty seconds ago.

The server logs looked clean. The encode side was emitting monotonic presentation timestamps. The pacing diagnostics all said the frames were going out on schedule. From the server's perspective, nothing was wrong.

From the viewer's perspective, the show kept starting over.

The first clue was a GStreamer warning buried in a wall of noise:

WARN hlsng m3u8.c:2251:gst_hls_media_playlist_sync_to_playlist:
  Could not synchronize media playlists

It fired roughly every twenty to thirty seconds. Once we knew to look, we found five of those warnings in a ninety-second session, and the intervals lined up almost exactly with the user's "plays for thirty, jumps back twenty" cadence.

Here is what was happening.

GStreamer ships two HLS demuxers. The newer one, hlsdemux2, is part of an umbrella plugin called adaptivedemux2; it handles low-latency HLS, adaptive bitrate switching, and a pile of modern features. The older one, hlsdemux, is the workhorse from the earlier plugin generation. Both are in the framework we bundle.

When hlsdemux2 refreshes a live playlist (live HLS playlists rewrite themselves every ten seconds or so, with new segments rolling in and old ones aging out), it tries to figure out where in the new playlist the previous playlist's playhead should now be. It does this by matching segment URIs. If it finds a segment in the old playlist whose URI also appears in the new playlist, it knows its position and keeps playing from the next one.

Many IPTV providers, including the one our tester was using, rotate a cache-busting token in the segment URL on every refresh. The content of segment number 412 does not change, but the URL looks different every time you fetch the playlist. To hlsdemux2, they look like completely unrelated playlists with no overlap. Sync fails. The warning prints.

When sync fails, hlsdemux2's fallback is to pick a segment still inside the live window. That segment is, by definition, from about twenty seconds ago, because the live window contains the last handful of segments the provider has published. hlsdemux2 re-downloads it, re-decodes it, and pushes those frames into the pipeline. Our encoder, which has no idea anything unusual happened, paces them with fresh wall-clock timestamps and sends them to the client.

The client, for its part, sees perfectly continuous video with monotonically increasing timestamps. There is no glitch in the player. There is no buffering icon. There is no stall. The clock ticks forward. The frames just happen to show content from twenty seconds in the past.

  What the player sees                 What the viewer sees

    t=28s  [frame N]                     "and the score is..."
    t=29s  [frame N+1]                   "...three to two..."
    t=30s  ← sync fails here             [seamless playback]
    t=31s  [frame M]                     "...that halftime show was..."
    t=32s  [frame M+1]                      (wait, what?)

    where M corresponds to ~10s before N in real-world time

The older hlsdemux uses a different strategy. It tracks its position by media sequence numbers and segment durations, not URIs. The cache-busting tokens that broke hlsdemux2 are invisible to it; it just looks at the segment numbering, which stays stable across playlist refreshes. No sync failures. No rewind.

The fix ended up being a one-line preference: for live HLS, prefer hlsdemux; for VOD (a theoretical future case), keep preferring hlsdemux2. Both are in the framework, both are already registered, both were in our fallback logic. We just had to flip the primary choice for live streams.

Swapping the demuxer had one complication worth mentioning, because it is a perfect example of how GStreamer's abstraction leaks. The two demuxers expose different numbers of output pads. hlsdemux2 splits the container internally and hands you one pad per elementary stream: one for video, one for audio, one for each subtitle rendition. hlsdemux hands you a single pad with the whole MPEG-TS stream still bundled together, and you are expected to route it through a downstream demuxer yourself.

The first time we flipped the preference, the pipeline stuck in READY state with a cryptic failed delayed linking warning. The first demux. branch grabbed the one pad hlsdemux had to offer. The second branch had nothing to link to and sat there dangling, which is apparently indistinguishable from "the pipeline is broken, refuse to start."

The fix was to route hlsdemux's single output through a shared parsebin that fans out to video and audio downstream, which is the idiom hlsdemux was designed for in the first place. Two demuxers, two pipeline shapes, one if-statement deciding which one to build.

Credentials, And Why The Client Never Sees Them

IPTV authentication is a minefield. Providers hand you passwords. Those passwords go into URLs, into HTTP headers, into API query parameters, and occasionally into places they should absolutely not go, like logs and crash reports.

ShowShark stores provider passwords in the macOS Keychain, under the service identifier com.acgao.ShowShark.iptv, with the provider's UUID as the account name. This mirrors the same pattern we use for S3 and SMB credentials. The config struct that represents a provider does not contain the password field at all; it holds a UUID, and the password is retrieved on demand from Keychain when a stream URL needs to be constructed.

M3U playlists sometimes encode credentials inline: http://user:[email protected]/live/channel.m3u8. When a user adds a playlist URL that contains inline credentials, the server extracts the password into Keychain, rewrites the stored URL to strip the inline auth, and reconstructs the credentials as an Authorization: Basic header at play-time. The password never persists on disk in plain text. A stolen laptop database yields exactly nothing useful about any user's subscriptions.

Log output gets the same treatment. Any line that would have contained a stream URL, including the debug logs GStreamer itself produces on warnings, passes through a redactor that rewrites the URL to http://<provider>/<path-redacted>. Signed CDN URLs (the long ones with auth tokens in the query string) get specifically identified and flagged so a verbose log file can be shared with support without leaking a subscription. If you have ever debugged a cloud service and accidentally copy-pasted a screenshot with a signed URL into a Slack thread, you understand why this matters.

Logos get their own security treatment. Providers send channel logos as arbitrary HTTP(S) URLs, and the server fetches those logos to cache them for the client. Without a filter, a malicious playlist could use those fetches to probe an internal network (the classic SSRF attack). The logo fetcher validates every URL against an allowlist of schemes and explicitly rejects IPv4 literals that point inside private ranges, loopback, or link-local space. The server will not use itself as an exfiltration proxy.

The client, meanwhile, has been built from day one around the assumption that it can only ever reach one host: its own server. When it asks for a channel, it asks by opaque numeric ID. When the server sends back a logo to display, the server is streaming a cached image over the WebSocket; the client does not make an HTTP request to any third-party hostname. From the network's point of view, a user watching twelve hours of IPTV looks identical to a user watching twelve hours of ripped Blu-rays: one TLS session to the ShowShark server at home. What is inside that tunnel is nobody else's business.

What It Feels Like To Use

If you are running the build right now and you have a legitimate IPTV subscription and you have added your provider in the server's Channels tab, here is the end-to-end user experience:

  1. Open the client. Tap the Channels tab.
  2. See a tile for each provider you have configured, alongside your local simulated-live channels.
  3. Tap a provider. See the categories. Tap a category. See the channels.
  4. Tap a channel. It plays. The player chrome shows the channel name and what is "on now" according to the EPG.

No HLS URL is visible anywhere. No provider hostname is visible anywhere. No authentication credentials leave the server. The client app, running on your phone in a coffee shop, sees a single encrypted WebSocket back to your Mac. The Mac is the one brokering a connection to the provider's CDN. If your provider rotates a cache-busting token on every playlist refresh, the demuxer rolls with it. If the server needs to transcode the stream to H.265 to fit the pipe between your house and the coffee shop, it does, and the client does not know about any of it.

All the complexity, every last scrap of it, lives in the one place it should: on the machine you trust, at home, behind your own firewall, speaking the protocol you already speak to it.

The client is just a player. As far as it is concerned, it is playing a local file.

That is the ShowShark pitch, and this is the seventeenth feature we have shipped in defense of it.