
Tracing Work Through My Jellyfin Media Stack
How playback selection, frame processing, mount topology and delivery deadlines shape a self-hosted media system.
When I inspected the Intel graphics capabilities in my media server, AV1 appeared among the supported decoding formats, but AV1 encoding did not. That distinction made the hardware acceleration setting much more interesting. A machine could understand an incoming format without being able to produce that format through the same hardware path.
I had deployed Jellyfin in Docker on my OpenMediaVault server, alongside library-management applications, shared storage and a VPN-connected transfer client. The installation gave me control over the system. Understanding its behavior required a more specific question: what work would this particular playback request make the system perform?
The account below starts from my reported configuration and capability checks. The frame graphs, filesystem layout and calculations are illustrative, not reconstructed historical manifests or new measurements. Current documentation explains the mechanisms; the exact historical CPU, driver and application releases are not recorded.
There are two workflows to keep distinct. Transfers and library imports make a file available. Jellyfin then reads it and serves a client-selected representation. My Gluetun configuration belongs to the transfer workflow; the account does not establish that Jellyfin playback uses that VPN. Following these paths separately prevents a whole-host measurement from hiding which application is doing the work.
The client helps choose the server's workload
A media file combines several decisions: the container holding the streams, the video codec and profile, the audio format, and any subtitles. Support for one does not imply support for the others. Jellyfin receives client constraints and chooses a compatible playback path.
Its dedicated transcoding documentation distinguishes four modes. Direct Play sends the original file. Remux changes the container while preserving encoded audio and video. Direct Stream converts audio while preserving video. A video transcode decodes and re-encodes the video. These distinctions identify which expensive operations are necessary. Jellyfin's playback definitions are more useful here than treating every changed stream as the same workload.
Consider an illustrative file with AV1 video, an audio format unsupported by a particular client, and optional subtitles. Client A accepts the video but requires different audio, so the video can remain encoded as AV1. Client B requires H.264 video instead. For the worked case, assume the server supports the source's particular AV1 profile and the requested H.264 encoding configuration. This asks the hardware to decode AV1 and encode H.264, not to encode AV1.
Selecting subtitles adds another decision. If the client can render them separately, the video frames need no subtitle compositing. If it requires burn-in, the server must alter the frames. I will follow that latter case through processing and delivery below.
This makes client compatibility an architectural choice. Choosing a client that accepts the existing representation can avoid a transformation entirely. Pre-encoding a compatible version moves work away from playback time, at the cost of preparation, extra storage and another representation to maintain. On-demand conversion preserves flexibility but creates a deadline for the server.
The relevant capacity question is therefore conditional: which input, which requested output, which client settings, and which other work is active? A resolution label such as “4K” leaves most of that question unanswered.
A capability list sits before the execution path
My setup used Intel integrated graphics. I configured access to the Linux render interface, checked the iHD driver and inspected its reported codec capabilities. That was meaningful progress, but it established a narrower result than successful accelerated playback under every set of conditions.
Small Intel systems expose video acceleration through their integrated GPU; the capability list says what it can decode and encode. Photo: Laserlicht / Wikimedia Commons, CC BY-SA 4.0
Intel's driver exposes decoding, encoding and video processing as distinct functions, with support depending on platform and driver build. A codec name alone hides these differences. Intel's media-driver documentation describes the separate hardware functions and capability matrix.
VA-API makes the distinction explicit. An application queries a profile and its supported entrypoints, then creates an appropriate configuration and processing context. A decoding entrypoint and an encoding entrypoint are different capabilities. The API's own transcoding example configures them separately and passes decoded surfaces between stages. This is why my reported AV1 decode support could coexist with absent AV1 encode support. The VA-API interface expresses this as a negotiated configuration, rather than one universal “supports AV1” property.
The permission path matters too. A Linux render node lets an application submit GPU work without acquiring display modesetting authority. Access is controlled through device permissions. The kernel's render-node design explains that boundary.
For a container, mapping the correct render device and granting the actual service process the appropriate group access are separate requirements. A successful probe run as root does not establish that an unprivileged service user can open the device. Group numbers must come from the host rather than being copied from an example. Jellyfin's current Intel container instructions combine these grants with capability checks and an actual forced-transcode verification step.
Giving an application the required device is a deliberate authority grant. Blanket container privilege would obscure which access was necessary and expand the consequences of compromise. Neither approach creates codec support that the hardware and software stack lack.
Follow the frames, including the middle
A video transcode is a pipeline, not just an encoder invocation. Decoding creates frames. Depending on the request, processing may change their dimensions, pixel format or dynamic range, or composite subtitles. Encoding then produces a new compressed stream. Jellyfin distinguishes full and partial acceleration because a stage can remain on the CPU even when other stages use the GPU.
Two conceptual paths show why that matters:
Whether subtitle burn-in or tone mapping stays accelerated decides which path a request takes. Diagram: Daniel Buttigieg
Possible mixed path: compressed input -> hardware decode -> frames in system memory -> software processing -> hardware surfaces -> hardware encode -> compressed output
Whether subtitle compositing or tone mapping stays accelerated depends on the particular hardware, runtimes, formats and software implementation.
FFmpeg's `hwdownload` and `hwupload` filters express movement between hardware frames and system-memory frames. Its mapping interface also supports paths that can avoid copying when compatible. On integrated graphics, that boundary does not necessarily mean moving data across a discrete GPU's PCIe connection. It still matters for frame representation, synchronization and which processing code can operate on the data. [FFmpeg's filter documentation](https://raw.githubusercontent.com/FFmpeg/FFmpeg/n7.1/doc/filters.texi) makes those transitions inspectable.
For the AV1-to-H.264 case, suppose the selected implementation composites subtitles in software. The table shows how to interpret evidence from that test:
| Evidence to inspect | Bounded conclusion |
|---|---|
| Decoder initialization and negotiated VA-API hardware-frame output in the session log | The decoded frames use that hardware path; a requested option alone would not establish successful decoding |
| `hwdownload,format=nv12` in the executed graph | Hardware frames are exposed as supported NV12 software frames before CPU processing, assuming that download format is supported |
| A software subtitle-compositing stage, followed by any required software-format conversion | The middle of this request includes CPU work; the exact accepted formats must match the filters |
| `hwupload` feeding `h264_vaapi`, with successful initialization and encoded output | The compatible software frames return to hardware surfaces for the selected H.264 hardware encoder |
The [VA-API encoder contract](https://raw.githubusercontent.com/FFmpeg/FFmpeg/n7.1/doc/encoders.texi) requires hardware surfaces at its input. This explains the upload after software processing. An unsupported download format, missing filter or incompatible surface would invalidate the proposed graph rather than quietly prove acceleration.
I would retain the generated command alongside the successful execution log and check that output actually renders the subtitles correctly. If ordinary logs omit frame-format negotiation, the record needs more verbose diagnostic output. GPU-engine activity can corroborate hardware work but cannot identify every stage by itself. The selected input, client settings and interval production rate belong in that same record. Comparing this case with subtitles rendered by the client would isolate one changed request condition, while keeping source, output constraints and competing load comparable.
## Transfer speed needs a workload and a clock
I reported approximately 127 MB/s from direct-attached storage and 96 MB/s over SMB on Gigabit Ethernet. The test commands, transfer direction, file sizes, cache state and repeated results are not recorded. Those figures are useful recollections, but they cannot isolate a bottleneck.
Using decimal units, the arithmetic is:
```text
1,000,000,000 bits/s / 8 = 125,000,000 bytes/s = 125 MB/s
96 MB/s * 8 = 768 Mbit/s
768 / 1,000 = 76.8% of nominal line rate
That percentage is neither SMB efficiency nor proof that the link was saturated. Protocol overhead consumes capacity, but endpoint storage, CPU work, caching and concurrent activity can also limit an observed file copy. The local 127 MB/s path bypasses Ethernet, so it need not obey the network's 125 MB/s raw bound.
Before buying a faster component, I would separate the paths experimentally. A controlled network-throughput test between the same endpoints, in the relevant direction, removes the SMB file-copy operation from that measurement. A local storage test and a comparable SMB transfer then add their respective costs. ESnet's iperf3 documentation defines the network test and reverse direction. None of those tests alone establishes playback quality or durable storage writes.
For playback, even a well-measured average is incomplete. Processing and delivery must replenish playable media before the client's buffer empties.
For the illustrative AV1-to-H.264 request, assume equal six-second segments of 120 Mbit each, including audio. Each segment therefore averages 20 Mbit/s. Suppose producing each segment takes four seconds, and effective delivery runs at 30 Mbit/s, also taking four seconds per segment. Production is therefore 1.5 times real time. Use a deliberately simple model: each segment must be fully produced before transfer begins, production of the next segment overlaps transfer of the last, and protocol overhead and latency are omitted.
| Segment | Produced during wall-clock seconds | Transferred during wall-clock seconds | Playable buffer immediately after arrival |
|---|---|---|---|
| 1 | 0 to 4 | 4 to 8 | 6 seconds; playback starts at 8 |
| 2 | 4 to 8 | 8 to 12 | 8 seconds: 6 minus 4 plus 6 |
| 3 | 8 to 12 | 12 to 16 | 10 seconds: 8 minus 4 plus 6 |
The first segment takes eight seconds to arrive, but later arrivals are four seconds apart because the stages overlap. Adding the two stage durations for every segment would miss that distinction. These assumed rates describe one workload, not concurrent-stream capacity.
Change only effective delivery to 10 Mbit/s and each transfer takes 12 seconds. The first arrival moves to second 16. Starting playback with that single six-second segment empties the buffer at second 22, before the next segment arrives at second 28. A larger initial buffer delays the first stall but cannot sustain a continuing deficit of six playable seconds per transfer. If production continues unthrottled, three segments finish for each one delivered: queued output grows by two segments every twelve seconds once the stages are busy. Flow control or bounded queues must change that producer schedule. Real encoders and players also vary segment sizes, overlap work differently and adapt their requests.
In this complete-segment model, if the post-arrival buffer is B seconds and the next arrival is delta seconds later, the next post-arrival buffer is max(0, B - delta) + 6. Any intervening stall lasts max(0, delta - B) seconds. The fast case adds two buffered seconds per arrival; the slow case cannot replenish media as quickly as playback consumes it.
The extended BOLA manuscript examines one response to a slow segment: abandoning its download for another representation before the buffer empties. This trace-driven simulation concerns prerecorded choices, not Jellyfin's live encoder or my clients' actual algorithm. It explains why the fixed-request model above needs an explicit adaptation assumption.
Shared paths are an interface, and mounts change the operation
The supporting applications made storage layout part of the architecture. I used Sonarr, Radarr and Lidarr for library workflows, Prowlarr for indexer integration, Seerr for requests, and qBittorrent for transfers. Their existence in the stack did not make them agree automatically on where a file lived.
A path communicated between applications is an interface. If one service reports /data/incoming/example.mkv, another needs a mapping that resolves that name to the intended file, or an explicit translation. Matching path strings are useful only when the underlying mappings also agree.
There is a second issue beneath naming. An importing process might link, rename or copy a file. Those operations have different storage consequences.
A hardlink adds another name for the same file. It does not create an independent copy: later changes to its content are visible through either name. Linux also refuses to create a hardlink across different mounts, even when they expose the same underlying filesystem. Permissions and filesystem support impose additional conditions. The link system-call contract is more specific than “the paths are on the same disk.”
An illustrative layout for an importer is:
One host parent, exposed through one bind mount:
/srv/media-data -> /data
/incoming
/library
If both directories remain on the same mounted filesystem in the importing process, this arrangement avoids introducing a mount boundary between them. Mounting the two directories independently can introduce that boundary. Putting two separately mounted disks under a shared parent does not remove it. The names alone cannot tell us which case applies.
A supported same-mount rename can change a directory entry without copying the entire media object. Linux returns EXDEV across mount points; an application may then need a different strategy, potentially a copy. Atomic namespace replacement also does not establish persistence after a crash. The rename documentation defines the narrower guarantee.
This creates a useful trade-off. A broad shared parent can simplify imports, but it also exposes more paths to a writer. A playback service need not receive every import permission. A proposed layout can give Jellyfin read-only media access while retaining writable configuration and cache locations. Check enforcement on any included submounts as well; a read-only parent alone does not establish that property across every kernel and mount configuration. Docker's bind-mount controls constrain writes through a mount; they do not stop another authorized process from modifying the same underlying file.
My original account establishes consistent-path planning and separate configuration and bulk storage. It does not establish which import operations occurred. To test the illustrated layout, inspect both paths from inside the importing process's mount namespace. A tool such as findmnt can resolve a target path against its mount table; compare mount identities as well as backing storage. Then use a disposable fixture or application trace to establish which operation the importer actually performs. A successful link and matching file identity would demonstrate shared content for that fixture, not an independent backup or a guarantee about every later import.
A VPN egress check and a DNS failure can both be real
My transfer client shared Gluetun's network namespace. I checked that its external egress used the VPN, yet also encountered DNS-related health failures. Those observations concern different operations and potentially different moments.
This is the supporting transfer path identified earlier. Its health affects that workflow, while its CPU, disk and network activity can compete with playback on the same host. Treating it separately keeps a transfer-client DNS problem from becoming an unsupported diagnosis of Jellyfin.
The documented network_mode: "service:gluetun" arrangement joins the service's network stack. Routing, interfaces and listening ports then share that network context. It does not merge every container resource or automatically make every resolver configuration identical. The Gluetun connection instructions define the arrangement, while its port-mapping guidance explains why publication and listener collisions require attention on the shared stack.
An external-IP response shows which egress served that request. Resolving a name exercises an additional dependency. Gluetun's healthcheck description distinguishes startup and periodic probes, and warns against confusing downstream timeout symptoms with their cause. Without the original logs and version, a DNS message cannot identify my historical root cause.
There is also a separate security property: what happens when the VPN fails? Gluetun's firewall has a kill-switch design, but configured outbound exceptions change allowed routes and traffic. Joining the namespace and checking egress once do not certify every transition or an unknown configuration.
A future controlled test would distinguish tunnel interruption and internal VPN recovery from restarting or replacing the Gluetun container itself. The latter also tests whether dependent applications regain usable networking. In each case, record IPv4, IPv6 where enabled, resolver traffic and intentional exceptions while preserving firewall protection. Those results would support a bounded traffic-policy claim, not anonymity or protection of every application on the host.
The record I would keep next
The next investigation would begin with an identified source fixture and a named client configuration. First establish that Jellyfin can read the intended file and why the client selected its playback mode. If video transformation is needed, inspect the executed graph and correct output. Then compare production and delivery intervals with the playback buffer. Investigate the transfer workflow separately when its failures or competing activity are relevant. Record versions, settings and outcomes so another run can challenge the conclusion.
That sequence gives the opening AV1 observation a practical consequence. A supported AV1 source may feed a supported H.264 hardware encoder even when AV1 hardware encoding is absent. An AV1 output target asks a different question. My capability inspection helped identify that choice; a retained playback trace would show which path a real request took and whether it met its deadline.
Image credits
- Cover: Jellyfin v10.6.0 movie detail, web client by Jellyfin, GPL, via Wikimedia Commons
- Intel NUC8 by Laserlicht, CC BY-SA 4.0, via Wikimedia Commons
- Diagrams by Daniel Buttigieg.