nateparker.dev

← Writing

Fixing the Plex Linux client by taking away its Qt

Plex's desktop app opened as a blank window on my NVIDIA machine, and the usual workarounds lost the video. The real cause was the four-year-old Qt build Plex ships inside the app. Here's how I got it running on Arch's own Qt instead, with GPU rendering and video intact.

7 min readlinuxomarchydebugging
A vintage film projector on a table with its ornate old lens unscrewed and set aside next to a screwdriver, and a slim new lens fitted, throwing a violet beam across the page.

I wanted the native Plex client on my Omarchy desktop: Hyprland, an RTX 3090, NVIDIA driver 615. The AUR package plex-desktop installed fine. It opened a window with nothing in it.

The web player in a browser works, and that’s what most people settle for. But the native app plays more formats directly, so the server doesn’t have to transcode them, and I wanted it working. So I kept going.

The fixes everyone suggests

Search for this and you find the same handful of answers, in the AUR comments and the Plex forums:

  • Force X11 with QT_QPA_PLATFORM=xcb. Plex already runs under XWayland by default, and it made no difference.
  • Native Wayland instead. Same blank window.
  • Turn off the GPU for the web view with Chromium flags. Plex ignores most of them, because it sets its own.
  • Software rendering with QT_QUICK_BACKEND=software. This one works: the full interface shows up. Then you press play and get audio over a black rectangle.

The last one is the tell. Plex plays video through mpv, and mpv draws into an OpenGL framebuffer inside the Qt Quick scene. Take OpenGL away and the menus survive but the video can’t. Any fix that keeps playback has to keep the GPU.

Reading the error

Run the binary from a terminal and it says one thing, over and over:

ERROR:gl_context_egl.cc(277)] eglCreateContext failed with error EGL_BAD_CONTEXT
ERROR:gpu_channel_manager.cc(771)] ContextResult::kFatalFailure: Failed to create shared context for virtualization.

Over and over is literal: about 30 MB of this in twelve seconds. Plex’s UI is a web app in Qt WebEngine, and WebEngine is Chromium. Chromium’s GPU thread tries to create an OpenGL context that shares resources with Qt’s own context, so the page it renders can be handed to Qt Quick without a copy. NVIDIA’s EGL driver refuses the share, Chromium retries forever, and nothing reaches the window.

The question was why. Other Qt apps on this machine draw fine. A five-line QML test with a WebEngineView in it rendered on the first try.

Plex brings its own Qt

The answer was in the binary:

$ strings /opt/plex-desktop/lib/libQt6Core.so.6 | grep -m1 conan
/root/.conan_plex/.conan/data/qt6/6.2.4+0/plex/stable/build/.../qt-fork-6.2.4/...

Plex ships a private fork of Qt 6.2.4, from 2022, in its own lib/ directory. Arch has Qt 6.12. WebEngine’s GPU plumbing has been rewritten several times since 6.2, and newer versions don’t share contexts this way at all. My QML test worked because it used the system Qt. Plex didn’t.

So: could the Plex binary run on the system Qt?

Three things in the way

The library path. My first attempt set LD_LIBRARY_PATH to a directory with Plex’s non-Qt libraries (mpv, ffmpeg) and left Qt to come from /usr/lib. It loaded the bundled Qt anyway, and I spent a while testing the wrong thing before a backtrace showed /opt/plex-desktop/bin/../lib/libQt6Core.so.6. The binary has $ORIGIN/../lib in its RUNPATH. The loader searches LD_LIBRARY_PATH before RUNPATH, but RUNPATH before the system default, and my directory had no Qt in it. The fix is to name /usr/lib explicitly.

Symbol versions. With the right Qt loading, it refused to start:

undefined symbol: _ZN23QQuickFramebufferObject8Renderer11synchronizeEPS_, version Qt_6_PRIVATE_API

Qt tags exported symbols with version names, and Plex’s fork exported seven QQuickFramebufferObject::Renderer methods under Qt_6_PRIVATE_API. That’s the class mpv renders video through. System Qt exports the same seven methods, with the same signatures, under plain Qt_6. The code is all there; only the label differs.

Labels live in a table in the ELF file. .gnu.version holds one 16-bit index per dynamic symbol, pointing into .gnu.version_r, which lists the version names needed from each library. Rewriting the seven indices from Qt_6_PRIVATE_API to libQt6Quick’s Qt_6 fixes the lookup. Marking the now-unused Qt_6_PRIVATE_API requirement weak stops the loader from refusing to start because that version is missing. It’s a 2-byte change per symbol, made on a copy of the binary:

# Remap Plex's Qt_6_PRIVATE_API symbols to libQt6Quick's Qt_6 (full script below)
for i in range(versym['size'] // 2):
    o = versym['offset'] + 2 * i
    v, = struct.unpack_from('<H', data, o)
    if (v & 0x7fff) == priv_idx:
        struct.pack_into('<H', data, o, qt6_idx)
struct.pack_into('<H', data, priv_aux + 4, 0x2)   # VER_FLG_WEAK

A crash in Fontconfig. Now it started, and segfaulted in FcStrSetDestroy, called from Pango. Omarchy sets QT_QPA_PLATFORMTHEME=gtk3 so Qt apps match GTK ones. System Qt loads that theme, the theme starts GTK, and GTK’s font setup raced Plex’s own. Plex doesn’t need the theme, so the launcher unsets it.

Stuck on the splash screen

Next it drew the Plex logo on the GPU, without a single EGL error. Then it sat there.

I turned on Plex’s web inspector (webInspectorPort in plex.ini) and asked the page what it had loaded. One <script> tag hadn’t run:

<script src="qrc://:/qtwebchannel/qwebchannel.js"></script>

That script is the bridge between the web UI and the native app, and the splash waits for the bridge. qrc://:/ is an odd way to spell a Qt resource URL. Qt 6.2 accepted it and 6.12 doesn’t. qrc:/// does work. I confirmed it by injecting all three spellings from the console and checking which ones defined QWebChannel.

index.html is in /opt, which belongs to the package, so I didn’t edit it. Plex reads its resources from $PLEX_RESOURCE_PATH, so the launcher builds an overlay: symlinks to everything in Plex’s resources, plus a copy of index.html with that one URL fixed.

After that, the full interface came up, on the GPU, with the server connected. Pressing play gave direct play of a 10-bit HEVC file, through mpv, with picture.

The fix

Two files. A Python script patches a copy of the binary, and a launcher replaces Plex’s own start script. The launcher re-runs the patch whenever the package updates.

#!/usr/bin/env python3
"""Patch a copy of plex-desktop's bin/Plex so it runs on the system Qt 6.
Usage: patch-plex.py /opt/plex-desktop/bin/Plex ~/.local/lib/plex-fix/bin/Plex"""
import struct, sys

src, dst = sys.argv[1], sys.argv[2]
data = bytearray(open(src, 'rb').read())
e_shoff, = struct.unpack_from('<Q', data, 0x28)
e_shentsize, e_shnum = struct.unpack_from('<HH', data, 0x3A)

secs = []
for i in range(e_shnum):
    f = struct.unpack_from('<IIQQQQIIQQ', data, e_shoff + i * e_shentsize)
    secs.append(dict(type=f[1], offset=f[4], size=f[5], link=f[6], info=f[7]))
verneed = next(s for s in secs if s['type'] == 0x6ffffffe)   # .gnu.version_r
versym = next(s for s in secs if s['type'] == 0x6fffffff)    # .gnu.version
strtab = secs[verneed['link']]

def cstr(o):
    b = strtab['offset'] + o
    return data[b:data.index(b'\0', b)].decode()

priv_idx = qt6_idx = priv_aux = None
off = verneed['offset']
for _ in range(verneed['info']):
    _, cnt, file, aux, nxt = struct.unpack_from('<HHIII', data, off)
    a = off + aux
    for _ in range(cnt):
        _, _, other, name, anext = struct.unpack_from('<IHHII', data, a)
        if cstr(file) == 'libQt6Quick.so.6':
            if cstr(name) == 'Qt_6_PRIVATE_API':
                priv_idx, priv_aux = other, a
            elif cstr(name) == 'Qt_6':
                qt6_idx = other
        a += anext
    off += nxt

assert priv_idx and qt6_idx
for i in range(versym['size'] // 2):
    o = versym['offset'] + 2 * i
    v, = struct.unpack_from('<H', data, o)
    if (v & 0x7fff) == priv_idx:
        struct.pack_into('<H', data, o, qt6_idx)
struct.pack_into('<H', data, priv_aux + 4, 0x2)   # VER_FLG_WEAK
open(dst, 'wb').write(data)
#!/bin/sh
# ~/.local/bin/plex-desktop — run plex-desktop on the system Qt 6
B=/opt/plex-desktop
F=$HOME/.local/lib/plex-fix

# Re-patch when the package updates.
if [ ! -x "$F/bin/Plex" ] || [ "$B/bin/Plex" -nt "$F/bin/Plex" ]; then
  mkdir -p "$F/bin"
  python3 -I "$F/patch-plex.py" "$B/bin/Plex" "$F/bin/Plex" && chmod +x "$F/bin/Plex"
fi

# Resources overlay with the qwebchannel.js URL fixed.
R=$F/resources
if [ ! -f "$R/web-client/index.html" ] || [ "$B/resources/web-client/index.html" -nt "$R/web-client/index.html" ]; then
  rm -rf "$R" && mkdir -p "$R/web-client"
  for f in "$B"/resources/*; do [ "$(basename "$f")" = web-client ] || ln -s "$f" "$R/"; done
  for f in "$B"/resources/web-client/*; do [ "$(basename "$f")" = index.html ] || ln -s "$f" "$R/web-client/"; done
  sed 's|qrc://:/|qrc:///|g' "$B/resources/web-client/index.html" > "$R/web-client/index.html"
fi

# Plex's own mpv/ffmpeg, but not its Qt.
if [ ! -d "$F/lib" ] || [ "$B/lib" -nt "$F/lib" ]; then
  rm -rf "$F/lib" && mkdir -p "$F/lib"
  for f in "$B"/lib/*; do case $(basename "$f") in libQt6*|libicu*) ;; *) ln -s "$f" "$F/lib/";; esac; done
fi

export LD_LIBRARY_PATH=$F/lib:/usr/lib
export LC_ALL=C.UTF-8
export FONTCONFIG_PATH=${FONTCONFIG_PATH:-/etc/fonts}
export QTWEBENGINEPROCESS_PATH=/usr/lib/qt6/QtWebEngineProcess
unset QT_QPA_PLATFORMTHEME QT_PLUGIN_PATH QML_IMPORT_PATH QML2_IMPORT_PATH
export DISABLE_WAYLAND=${DISABLE_WAYLAND:-1}
export PLEX_USERDATA_DIR=${XDG_DATA_HOME:-$HOME/.local/share}/plex
export PLEX_RESOURCE_PATH=$R PLEX_MEDIA_SERVER_RESOURCES=$R
export PLEX_CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache}/plex
export PLEX_STORAGE_INI_DIR=$PLEX_USERDATA_DIR PLEX_LOG_DIR=$PLEX_USERDATA_DIR/Logs
exec "$F/bin/Plex" "$@"

Save the script as ~/.local/lib/plex-fix/patch-plex.py and the launcher as ~/.local/bin/plex-desktop. You need qt6-webengine, qt6-declarative, qt6-webchannel and qt6-positioning installed. Point a copy of the desktop file in ~/.local/share/applications/ at the launcher and the app menu uses it. Nothing in /opt changes, so a package update can’t silently undo it, and the patched binary is never shared.

A wrong turn worth admitting

The library path above cost me a run of “system Qt” results that were really the bundled Qt. Nothing failed; it just quietly loaded the wrong thing. When a fix changes which library loads, read /proc/<pid>/maps before you trust a result.

What this costs

It’s unsupported. Plex built this binary for its Qt 6.2 fork. Running it on 6.12 works for everything I’ve tried, but Plex has never tested it, and a Plex update could add another private symbol or another URL that newer Qt rejects. The launcher re-patches automatically, but it can’t fix a new kind of break.

It’s a patched proprietary binary. The patch only relabels symbol versions in a copy on your own machine. Share the script, not the result.

It’s tested on one setup: RTX 3090, NVIDIA 615, Hyprland 0.56, Qt 6.12, plex-desktop 1.115. If you’re on Mesa, you probably never had this problem.

The real fix belongs upstream. Plex’s Qt is four years behind and its web view can’t render on current NVIDIA drivers. Until Plex updates it, this gets you the native client with working video.