Movie Trailers: Playing YouTube Inside a Sandboxed Mac App

Part 15: ShowShark Development Blog

Open a movie's detail page in ShowShark and you will see a little movie-clapper icon next to the Play button. Tap it. A spinner flickers for a heartbeat, the backdrop fades, and the trailer plays. No ads. No cookie banner. No "Watch next" rail trying to pull you into YouTube's recommendation engine for the next forty-five minutes. Just the trailer, in a player that looks and behaves exactly like the rest of ShowShark because, as far as the client is concerned, it is playing a local file.

This post is the story of getting that button to work. It is the most end-user-trivial feature we have shipped; it is also the one that chewed through the most architecture. Along the way we rewrote a corner of the decode pipeline, adopted a Rust HTTP client, bundled a Python interpreter inside a signed macOS app, and ran face-first into the two cruelest words in the Apple ecosystem: "sandbox denial."

The Grandma Test

Early in the spec work, there was a tempting shortcut. We could tell the user: "Please brew install yt-dlp, and then point ShowShark at it." That would have saved us headache. It would also have failed what I call the grandma test.

The grandma test is the simplest rubric I know for consumer software: can someone's grandmother, who received a MacBook as a retirement gift and has never opened Terminal, install and use your app without calling anyone for help? If the answer is "well, first she needs Homebrew, and for Homebrew she needs Command Line Tools for Xcode, and for that she needs an Apple ID, and then…", you have already lost. You have outsourced the hard work to the user.

ShowShark is a personal media server. It runs in homes. The pitch is "install it, point it at your movies, open the client, press play." Every step beyond that is an apology. So "just install yt-dlp yourself" was a non-starter, regardless of how convenient it would have been for us as developers.

The opposite principle is what we settled on, and what I think distinguishes ShowShark from the alternatives: ease of use, even for advanced features, is part of the product. If the feature requires a scary binary, we ship the scary binary. If it requires a Python interpreter, we ship the Python interpreter. The complexity is ours to carry, not the user's.

That decision, made in the spec phase, is the reason the rest of this post is as weird as it is.

What "Play the YouTube Trailer" Actually Means

To understand the mess, you need to understand what YouTube hands out when you ask for a video these days. The short version: it hands you an obstacle course.

A YouTube URL like https://youtube.com/watch?v=abc123 is not a media URL. It is a page that contains a JavaScript bundle that, when executed, extracts a signed URL pointing at googlevideo.com, which is YouTube's actual CDN. The signature is generated by applying a series of character transformations to a cipher value embedded in the player's JavaScript. The transformations change every few weeks. If your extractor does not evaluate that JavaScript correctly, you get a 403.

This is why yt-dlp exists, and why it ships releases every two to five days. It is a small army of contributors keeping pace with YouTube's anti-automation changes. Rolling our own extractor, or picking a "pure Swift" library that has not been updated in months, would have put us on a maintenance treadmill that would eventually break in the field with no warning.

That part was expected. The next part was not.

For anything above 360p, YouTube does not give you a single muxed file. It gives you two URLs: one is a video-only MP4, and the other is an audio-only MP4. The client is expected to download both in parallel and interleave them using DASH-like timing. For our pipeline, which had spent its entire existence assuming that every input was a single container from which you demuxed video and audio, that was a surprise.

  What we expected                What YouTube actually does

  ┌─────────────────┐             ┌─────────────────┐
  │    one URL      │             │   video URL     │──► video-only MP4
  └────────┬────────┘             └─────────────────┘
           │
           ▼                      ┌─────────────────┐
  ┌─────────────────┐             │   audio URL     │──► audio-only MP4
  │  muxed MP4      │             └─────────────────┘
  │  video + audio  │
  └─────────────────┘              (client merges at playback time)

We had two choices: (a) download both streams server-side, re-mux them into a single MP4, then feed that to the existing pipeline, or (b) teach the pipeline to accept two inputs. Option (a) meant writing a muxer, paying its CPU cost, and adding latency. Option (b) meant editing the graph.

Option (b) won, and it turned out to be the cleaner answer anyway, because DASH streams in general will want this shape.

Two Demuxers Converging at the Same Appsinks

ShowShark's server transcodes media through a GStreamer pipeline. Inputs land on a decode graph; decoded raw frames pass through two named "appsinks" (basically Swift-readable buffers); encoded output then heads back out over the WebSocket to the client. The contract between decode and encode is that pair of appsinks: raw_videosink and raw_audiosink. As long as samples keep appearing there, the encode side has no idea where they came from.

That contract is what made the dual-URL case tractable. We added a new DataSourceMode called .httpStreaming, with an HTTPStreamingConfig enum underneath it. The .dualContainer variant describes the two-URL case. At pipeline build time, the graph grows a second top-level subgraph:

  Dual-container decode graph

  reqwesthttpsrc (video URL) ─► qtdemux ─► decoder ─► raw_videosink ─┐
                                                                     │
                                                                     ├─► encoder ─► WebSocket
                                                                     │
  reqwesthttpsrc (audio URL) ─► qtdemux ─► decoder ─► raw_audiosink ─┘

Two parallel pipelines, feeding the two appsinks that the rest of the server was already written against. The existing clocking code handled A/V sync for free, because YouTube's two streams share the same PTS origin, so their timestamps already line up.

The only subsystem that needed surgery was end-of-stream handling. For always we had trusted a simple rule: "consider the session done when both appsinks have delivered EOS." For single-container inputs, this is bulletproof; one demuxer drives both outputs, so EOS always arrives on both sides within milliseconds. For the dual-container case, though, one HTTP source might stall while the other finishes cleanly. If we waited forever, the session would hang; if we short-circuited, we would truncate legitimate streams.

The fix was a watchdog, cheap but careful. On first EOS, a background task starts polling every 500 ms. If one side has been EOS for at least five seconds, and during that same window the other side has produced exactly zero samples, we declare the session done. An idle-verified timeout, not a deadline.

  EOS watchdog for dual-container streams

        t=0s ─────────────► t=5s
           │                   │
           │   video side:     │   audio side:
           │   EOS arrives     │   still silent
           │   and samples     │   (no data pulled
           │   stop arriving   │    for 5 seconds)
           │                   │
           │                   ▼
           │              ┌──────────────────────────┐
           │              │ Both idle? Fire EOS to   │
           │              │ client exactly once.     │
           │              │ Idempotent if the        │
           │              │ natural AND fires later. │
           │              └──────────────────────────┘

The yt-dlp Binary, and Why Bundling It Is Not Trivial

A fully functional YouTube extractor is a moving target that requires a JavaScript runtime to evaluate signature ciphers. That sentence alone implies two things we now need to ship: yt-dlp itself, and a JS engine.

The JS engine was the easy choice. QuickJS, Fabrice Bellard's tiny single-file C interpreter, weighs about a megabyte. It is a supported EJS runtime for yt-dlp, and unlike Deno or Node (80 to 100 megabytes each), it is small enough to bundle without guilt. We build it from source and ship the resulting qjs binary.

The yt-dlp side is where things got interesting. Upstream distributes a prebuilt yt-dlp_macos, which is a PyInstaller "onefile" executable: at launch, it unpacks a bundled Python interpreter and the yt-dlp sources into a temp directory, then runs them. This is a common pattern for Python tools that want to be redistributable. On an unsandboxed Mac, it works perfectly. Inside a macOS app sandbox, as we were about to discover, it does not.

The Sandbox Disaster

ShowShark Server is sandboxed and hardened. That is how Apple wants Developer-ID-signed apps to behave, and it is how you stay out of trouble with Gatekeeper and notarization.

Early unit tests of TrailerResolver (the actor that invokes yt-dlp) passed. Smoke tests on a developer-ID build passed. The first time a non-developer real-device test ran under the production sandbox, yt-dlp_macos launched, unpacked itself, and immediately crashed with an error that looked like this:

semctl(IPC_SET): Operation not permitted

semctl is the System V IPC semaphore syscall. PyInstaller's bootstrap loader calls it to create a synchronization semaphore during its self-unpack. On a normal macOS process, that is fine. Inside an App Sandbox, semctl returns EPERM, regardless of any entitlement you can ask for.

Apple documents an entitlement called com.apple.security.temporary-exception.sysv-shm, which lets you use System V shared memory. It does nothing for semaphores. There is no sysv-sem entitlement. I searched Apple's forums. I searched Radar. I asked people. The consensus answer: "you can't."

Which meant PyInstaller's onefile was a dead end. The loader was calling a syscall that no sandboxed app can ever make. No amount of entitlement tuning would help us. We needed a fundamentally different way to ship Python.

Option C: Zipapp and a Relocatable Python

The pivot we landed on, which we have been calling "Option C" in the spec, looks like this:

  1. Ship yt-dlp.pyz, a universal Python zipapp. It is just yt-dlp's source tree packaged as a zip file with a __main__.py entry. Python runs it with python yt-dlp.pyz. No unpacking. No IPC calls. No PyInstaller.
  2. Ship a relocatable CPython 3.12 interpreter from the astral-sh/python-build-standalone project. That project publishes prebuilt Pythons that are designed to run from anywhere on disk without writing to system paths. We use the install_only_stripped tarball and then further trim it: remove Tcl/Tk, pip, idle, the entire test suite, man pages, C headers. We keep only what yt-dlp actually needs at runtime.
  3. Ship a small bash launcher named yt-dlp that invokes the bundled Python against the bundled zipapp. Our Swift code calls the launcher; it does not need to know any of this exists.
  4. Ship quickjs alongside.

The whole tree lives at TrailerBinaries/bin/ in the repo. (It sits outside the ShowShark/ folder because Xcode 16's synchronized file groups have no way to exclude subdirectories; putting it inside the sync root caused every .py file in CPython's standard library to flatten into Contents/Resources/, where four separate util.py files from four separate stdlib packages collided. Relocating outside the sync root was the only fix that did not involve maintaining a handwritten exception list of hundreds of files.)

  Contents/Resources/bin/       (what ships inside the signed app)
  ├── yt-dlp                    small bash launcher
  ├── yt-dlp.pyz                the yt-dlp zipapp
  ├── quickjs                   the JS runtime
  └── python/                   trimmed relocatable CPython 3.12
      ├── bin/python3.12
      ├── lib/python3.12/       stdlib, minus the fat
      └── lib/libpython3.12.dylib

A Release-only build phase walks that tree post-copy and re-signs every Mach-O file inside it (the interpreter, libpython3.12.dylib, every .so in lib-dynload/, quickjs) with --options runtime under the Server's Developer ID. The launcher script and the zipapp are not Mach-O and are intentionally skipped. Notarization accepts the bundle; Gatekeeper is happy; the sandbox does not care, because nothing in the new runtime calls semctl.

The first real-device test under the fresh layout succeeded on the first try. No sandbox denials. No notarization complaints. yt-dlp --version prints in about 300 milliseconds, cold.

Wrapping a Subprocess Without Deadlocking

The last piece of infrastructure was the thing that calls the launcher. ShowShark had exactly one prior site that invoked an external process (LogCompressor, which shells out to gzip for diagnostics bundles). That implementation was synchronous, had no timeout, no cancellation, and did not drain stderr. It is fine for a short gzip; it would be a liability for a subprocess that might hang or emit hundreds of kilobytes of warning text.

So we wrote a new SubprocessRunner, a small async/await wrapper around Foundation.Process with four guarantees:

  SubprocessRunner guarantees

  ┌─────────────────────────────────────────────────────────────┐
  │  1. Hard timeout with a SIGTERM → SIGKILL cascade at T+2s   │
  │  2. Task.cancel() propagates to process.terminate()         │
  │  3. stdout and stderr drained concurrently                  │
  │     (stderr larger than 64 KB will not deadlock the pipe)   │
  │  4. Structured result: (stdout, stderr, exitCode)           │
  └─────────────────────────────────────────────────────────────┘

Each of these guarantees exists because its absence would, sooner or later, produce a user-visible hang. The stderr drain in particular is the kind of thing you only notice when something misbehaves in the field: a stuck yt-dlp that emits a warning every five seconds will fill a 64 KB pipe buffer in a few minutes, after which the process blocks on write and your "timeout" never fires because your read never returns. Two threads, pumping independently, costs nothing and removes that entire category of bug.

TrailerResolver, the actor that actually calls yt-dlp, layers a few more niceties on top: request coalescing (one inflight task per YouTube ID, so a double-tap does not launch two resolvers), TTL caching (clamped to an hour, since the CDN signatures are valid for six), a one-shot retry with 500 ms backoff for transient failures, and a taxonomy that distinguishes permanent errors (video deleted, geo-restricted, age-gated) from transient ones (timeouts, 5xx). Permanent errors clear the movie's stored YouTube ID in the database so the button disappears on the next render; transient errors do not, because the trailer is probably fine and we are just having a bad minute.

Signed CDN URLs never appear in info-level logs. debug is fine for development; anything above that strips the signature parameters, because those URLs are passport-free download tokens until the signature expires.

A Detail That Could Have Ruined Everything: Source Rank

There is one piece of GStreamer lore that I want to record here, because it took a half-day to find and the failure mode was genuinely opaque.

GStreamer plugins have a concept of "rank": a numerical priority that auto-selection mechanisms use to choose between equivalent elements. Our bundled framework ships two HTTP source elements. souphttpsrc, the default, is written in C and backed by libsoup. reqwesthttpsrc, the one we wanted, is written in Rust and backed by the reqwest crate. Both understand https:// URIs. Both implement the same source interface.

But souphttpsrc is ranked PRIMARY, and reqwesthttpsrc is ranked marginal. This is a decision made by whoever packaged the framework, not by us. And when GStreamer's gst_discoverer_discover_uri probes an HTTPS URL to find out what format it is, it auto-selects a URI handler by rank. Which meant our media detection was quietly routing through souphttpsrc even though our actual pipeline used reqwesthttpsrc by name.

In testing, this worked. Some trailers played, some failed, always with a TLS error that pointed nowhere obvious. The culprit turned out to be the redirect handling: YouTube's signed URLs serve a 302 to the nearest CDN edge, and souphttpsrc's TLS stack interacted poorly with a specific class of redirect responses from googlevideo.com. reqwesthttpsrc, using reqwest's redirect follower, handled them without blinking.

The fix is three lines in DecodePipeline+MediaDetection.swift:

func bumpReqwestRank() {
    guard let feature = gst_registry_lookup_feature(
        gst_registry_get(), "reqwesthttpsrc"
    ) else { return }
    gst_plugin_feature_set_rank(feature, GST_RANK_PRIMARY + 1)
}

We call it once, at first HTTP discovery. After that, everything that consults the rank picks reqwesthttpsrc first, and trailers stop failing with TLS errors. If anyone ever refactors that call out of the codebase, this blog post is the primary documentation that it was load-bearing.

The Client: Making It Feel Native

The server work is invisible to the user. The client work is the entire experience.

We added a new variant to the client's PlaybackContext enum:

case trailer(
    movieID: String,
    movieTitle: String,
    posterURL: String?,
    initialOffset: TimeInterval
)

The player chrome already branched on PlaybackContext for things like display title, download button visibility, and auto-advance behavior. Adding a fourth case meant four small conditional branches: hide the Download button for trailers, hide the subtitle selector (YouTube trailers rarely have them and we do not extract auto-captions), hide the watch-party invite, skip auto-advance, and on end-of-stream, navigate back to the movie detail view instead of playing the next file. The player did not need to know the path was really a YouTube CDN URL; it just plays whatever the server sends.

On the wire, the client sends a StartPlaybackRequest with a path field of trailer:<movieID>. The server sees the trailer: prefix before any virtual-path logic runs, routes to the trailer handler, and resolves the URL. We considered adding a new wire-protocol message type; we did not, because we did not need to. A four-character prefix is enough to dispatch on, and it keeps the protobuf schema quiet.

MPNowPlayingInfoCenter gets populated from the context's movieTitle and posterURL, so Control Center and the lock screen show "Trailer — Dune: Part Two" with the movie's poster art. No metadata round trip; the sender already had both values in hand when it constructed the context.

The button itself is a movieclapper SF Symbol on the movie detail view, visible only when the server reports trailerAvailable: true for that movie. A background backfill job populates that flag for every movie in the library on first server startup after the schema upgrade, then re-checks every seven days to pick up trailers added to TMDB after the original scan. If a resolution fails permanently (video taken down, say), the server clears the stored YouTube ID; the button disappears on the next render; the movie still exists; nothing else changes.

What the User Sees

  The end-to-end flow, from tap to first frame

  Tap button                                    ~0 ms
       │
       ▼
  Client builds PlaybackContext.trailer         ~1 ms
       │
       ▼
  Navigate into VideoPlaybackView               ~50 ms
       │
       ▼
  Send StartPlaybackRequest(path: "trailer:X")  ~100 ms
       │
       ▼
  Server: TrailerResolver dispatches yt-dlp     ~1–3 s
       │   (bundled Python + zipapp + QuickJS)
       ▼
  Server: build dual-container HTTPStreaming    ~50 ms
          pipeline, probe both URLs
       │
       ▼
  Server: reqwesthttpsrc begins fetching both   ~500 ms–1 s
          streams; qtdemux reads moov atom
       │
       ▼
  First frame arrives at client                 ~3–5 s total

Three to five seconds from tap to picture is the budget. The overlay copy says "Loading trailer…" during the wait. It reliably plays.

The Thing I Keep Coming Back To

The feature itself is small. Look at what ShowShark does: transcode arbitrary media in real time, stream over WebSocket with adaptive bitrate, handle pause and seek with full pipeline teardown, synchronize across PTS domains, render subtitles client-side. Adding a trailer button should be a weekend. It turned into two weeks.

Most of that time went into problems the user will never see. The semctl denial. The zipapp pivot. The re-signing of a trimmed Python interpreter. The source rank bump. The watchdog for one-sided EOS on DASH streams. The subprocess drain. Each of these problems was small. None was in the spec. All of them had to be solved for the button to exist.

This is what "ease of use, even for advanced features" costs. Not every product team is willing to pay it, which is why a lot of consumer software reaches for the "just install this other tool first" shortcut. The grandma test is not a standard because meeting it is hard. But I think it is the right standard for software that lives in people's homes, running all day, with the expectation that it will simply work.

Hit the clapperboard. Watch the trailer. The machine has carried the weight.