Skip to content

Fix Live TV HLS playback: bogus audio encoder and unplayable direct played manifests - #17768

Merged
crobibero merged 1 commit into
jellyfin:masterfrom
Shadowghost:fix-livetv-hls-audio-codec-and-manifest-direct-play
Sep 5, 2026
Merged

crobibero merged 1 commit into
jellyfin:masterfrom
Shadowghost:fix-livetv-hls-audio-codec-and-manifest-direct-play

Conversation

@Shadowghost

Copy link
Copy Markdown
Contributor

Changes

  • InferAudioCodec no longer echoes an unrecognised container back as a codec
  • M3UTunerHost stops advertising direct play for channel URLs that point at an HLS/DASH manifest, reading the extension from the URI path so a query string doesn't hide it
  • StreamBuilder rules out direct play for any source probed as hls/applehttp/dash, covering manifest URLs with no extension and other tuner hosts
  • Together these stop Jellyfin proxying an upstream master playlist verbatim, whose relative variant URIs 404 when resolved against /Videos/{id}/

Code assistance
Claude Opus 5 for reproduction help and tests

Issues
Fixes #17765

@Shadowghost
Shadowghost requested review from a team and gnattu September 2, 2026 07:32
@jellyfin-bot

Copy link
Copy Markdown
Contributor

Jellyfin API (v 12.0.0)


Result


No differences. Specifications are equivalent

@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

@theguymadmax theguymadmax moved this from Todo to Review in Jellyfin v12 Sep 2, 2026
@crobibero

Copy link
Copy Markdown
Member

Can this still allow direct play? I think it basically reverts #17682

@theguymadmax

Copy link
Copy Markdown
Member

Tuners:
HD Homerun tuners
M3u tuners (m3u8, m3u, mpd)

For 12.0
HD Homerun tuners will direct play - this PR does not affect it
M3u Tuners will no longer direct play and will be remuxed with this PR.

All tested clients (web, Android TV, Swiftin, Mobile) previously direct-played (near-instant startup). With PR applied all clients remuxued (Android TV client took 10secs for the stream to start ).

@gnattu gnattu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be okay if tuners are confirmed to work with this

@crobibero
crobibero merged commit 441a05a into jellyfin:master Sep 5, 2026
16 checks passed
@github-project-automation github-project-automation Bot moved this from Review to Done in Jellyfin v12 Sep 5, 2026
cewert added a commit to jellyrock/jellyrock that referenced this pull request Sep 22, 2026
…irect play

The override that plays a 12.x live HLS channel from its own URL treated
every server "no" as jellyfin/jellyfin#17768's. The answer does not say
which rule declined, and three older rules, which 10.11 applied and
JellyRock obeyed, were overridden too: the user's "Force transcoding of
remote media sources such as Live TV" policy on a remote source, the
tuner's "Auto-loop live streams", and the server's internet streaming
bitrate limit.

getUpstreamHlsBlocker() replaces shouldDirectPlayUpstreamHls() and returns
which rule keeps a channel on the server's stream (UpstreamHlsBlocker),
which the loader logs. The live and forced-transcode checks moved into it
from the call site: the forced-transcode one is what stops a failed
upstream from being retried forever, and it had no test. The request is an
interface (UpstreamHlsRequest), so renaming forceTranscoding at the call
site fails the build with argument-type-mismatch (tried).

Of the transcode reasons, only ContainerBitrateExceedsLimit counts.
Measured 2026-09-21 against the local 10.11.11 and 12.1.0 servers, on one
opened h264/aac fixture channel, with the same PlaybackInfo request and an
HEVC transcode target: 10.11 allowed direct play with no reasons, and 12.1
declined with VideoCodecNotSupported. For a manifest, 12.x skips its
direct-play check, so its codec reasons say only whether its own transcode
can copy the stream. A 1 Mbps cap added ContainerBitrateExceedsLimit on
both.

Not visible to the client, accepted: the tuner's "Simultaneous stream
limit". The server counts it when a live stream opens, and a retry of the
same channel joins the stream already open (consumer count 2, then 3, in
the 12.1 log) instead of taking a second slot, so the fallback is not
refused.

Verified 2026-09-21 on a Streaming Stick 4K, against the local 12.1.0,
12.0.0 and 10.11.11 servers with the synthetic HLS fixture:
- 12.1 and 12.0, separate-audio channel: upstream, playing in 2.6 s, no
  server ffmpeg. 12.1 muxed: 2.0 s.
- 10.11: blocker notDeclined, the server's own direct play, 3.8 s.
- 12.1 with the policy on: a remote source logs remoteSourcePolicy and
  takes the server's stream; a LAN source still plays upstream.
- Auto-loop on: blocker autoLoop on 12.1 and on 10.11. The server's own
  stream did not start within 70 s on either, so that is 10.11's
  behaviour, not a new one.
- A playlist that answers while its stream 404s on the device: direct
  play fails, the retry logs forcedTranscode and plays the server's
  stream at 32-37 s, with the stream limit at 0 and at 1.
- A remote source with the policy off (Apple's example stream): upstream
  after a 23-537 ms preflight. That stream is on demand, so it ended at
  once and the player restarted the channel in a loop, which is
  PlayerHostView's existing handling of a live channel that finishes.
- Unit 4116/4116 on device. Disabling the five new checks together fails
  exactly their six specs; deleting the bitrate check fails its two, and
  widening it to any reason fails the codec one.
cewert added a commit to jellyrock/jellyrock that referenced this pull request Sep 22, 2026
#990)

# Overview

Jellyfin 12.0 (jellyfin/jellyfin#17768) stopped offering direct play for
every `m3u` tuner channel whose URL is an HLS manifest. The server's
replacement, remuxing the stream itself, drops audio carried as a
separate HLS rendition (channels play silent) and starts about 30 s
slower. JellyRock never used the relay that change was fixing: it plays
the channel's absolute upstream URL. So when 12.x declines direct play
but still returns that URL, JellyRock now plays it directly again, as on
10.11.

The server's "no" does not say which rule declined, so the override
keeps the server's stream wherever a rule older than 12.0 applies, as
10.11 did: the retry that forces a transcode, the tuner's "Auto-loop
live streams", the user's "Force transcoding of remote media sources
such as Live TV", and the internet streaming bitrate limit.

The override is gated by a device preflight. A Roku handed a URL it
cannot reach buffers indefinitely instead of failing, so the app first
fetches the playlist from the device itself and plays it directly only
on HTTP 200 with an `#EXTM3U` body within 3 s. Anything else stays on
the server's stream.

## Changes

- `liveTv.getUpstreamHlsBlocker()` decides when to override. It returns
which rule keeps a channel on the server's stream, as an
`UpstreamHlsBlocker` value the loader logs, or `NONE`. It overrides only
the server's explicit "no" on an absolute `http(s)` HLS URL that does
not go through Jellyfin (the server's own host and port, the
`/LiveTv/LiveStreamFiles/` relay, `localhost` / `127.0.0.1`), and never
for DASH.
- It keeps the server's older reasons: the retry that forces a transcode
(which also stops a failed upstream from being retried forever),
`RequiresLooping`, the user's `ForceRemoteSourceTranscoding` policy on
an `IsRemote` source, and `ContainerBitrateExceedsLimit`. Other
transcode reasons are ignored: for a manifest, 12.x skips its
direct-play check, and its codec reasons say only whether its own
transcode can copy the stream.
- The request is a typed `UpstreamHlsRequest`, so a missing or
misspelled field fails the build rather than silently disabling a check.
- `LoadVideoContentTask.upstreamPlaylistAnswers()` is the device
preflight (`timeouts.LIVE_UPSTREAM_PREFLIGHT_MS` = 3000, verdict from
`liveTv.isHlsPlaylistResponse()`). It decodes `gzip`, because some CDNs
serve pre-compressed playlists even when the request doesn't ask for
them.
- The playback error dialog's "Play method" read "Transcoding" for every
playback, because `VideoPlayerView` never copied `isDirectPlaySupported`
from the loaded content. It now does; that field's only reader is the
dialog.
- Unit specs for both pure functions; each `getUpstreamHlsBlocker` case
asserts which blocker applies. `playback.md` and the
`live-hls-upstream-direct-play` decision note updated.

Accepted, and not visible to the client: the tuner's "Simultaneous
stream limit", which 10.11 applied by declining direct play. The server
still counts it when the live stream opens, and a retry of the same
channel joins that stream instead of taking a second slot.

Verified 2026-09-21 on a Streaming Stick 4K, against local 10.11.11,
12.0.0 and 12.1.0 servers with a synthetic HLS fixture:

- **12.1 and 12.0, separate-audio channel:** upstream, playing in 2.6 s,
no server ffmpeg. 12.1 muxed: 2.0 s.
- **10.11:** the server's own direct play (`notDeclined`), 3.8 s.
Unchanged.
- **Remote-source policy on:** a remote source takes the server's stream
(`remoteSourcePolicy`); a LAN source still plays upstream.
- **Auto-loop on:** the server's stream (`autoLoop`) on 12.1 and on
10.11. It did not start within 70 s on either, so that is 10.11's
behaviour, not a new one.
- **A playlist that answers while its stream 404s on the device:**
direct play fails, the retry logs `forcedTranscode` and plays the
server's stream at 32–37 s, with the stream limit at 0 and at 1.
- **Only the bitrate reason is a "no" from before 12.0:** with the same
request and an HEVC transcode target, 10.11 allowed direct play with no
reasons while 12.1 reported `VideoCodecNotSupported`. A 1 Mbps cap added
`ContainerBitrateExceedsLimit` on both.
- **Tests:** unit 4116/4116 on the device. Disabling the five new checks
together fails exactly their six specs; deleting the bitrate check fails
its two, and widening it to any reason fails the codec one.

Verified 2026-09-21 on a Roku Ultra, with the first version of the
override:

- **Upstream the TV cannot reach:** falls back to the server's stream
and plays at 34 s. Before the preflight it buffered forever.
- **Bad DNS or HTTP 404 upstream:** the error dialog in ~4 s.
- **A 12.1 server's real channels (two commercial CDNs):** direct play
with audio in ~2.1 s; the preflight took 57–129 ms.
- **`gzip`** is verified on the device only: on a channel whose CDN
sends it, it turned "not a playlist" and a 31.8 s server stream into
direct play in 2.1 s.

## Follow-ups

- #989: DASH (`.mpd`) channels. 12.x declines them too; play them from
their own URL.
- #988: a Live TV play that fails into the error dialog never closes the
server's live stream. This predates this PR.
- [A live TV channel whose stream ends restarts
forever](https://github.com/jellyrock/jellyrock/blob/main/docs/progress.md#components):
pre-existing in `PlayerHostView`; this PR makes it reachable on 12.x for
on-demand playlists listed as channels.
- [A live channel the server allows to direct play can buffer forever
when the TV cannot reach its
URL](https://github.com/jellyrock/jellyrock/blob/main/docs/progress.md#components):
the preflight runs only on the override path. Pre-existing.
- [A slow server probe fails a live channel's first start and leaks its
live
stream](https://github.com/jellyrock/jellyrock/blob/main/docs/progress.md#api):
pre-existing on every server version.
- [Playback Info shows no Audio section for a Live TV
stream](https://github.com/jellyrock/jellyrock/blob/main/docs/progress.md#components):
a display gap on 12.x.
- [Show the playback URL in Playback Info,
admin-only](https://github.com/jellyrock/jellyrock/blob/main/docs/progress.md#components)

## Issues

Ref #988
Ref #989

## Docs / context updates

- [x] **Architecture doc** updated if a system's *shape* or *why*
changed → `docs/architecture/<topic>.md`
- [ ] **`docs/dev/` how-to** updated if a workflow / recipe changed
(adding a setting, writing a migration, etc.)
- [ ] **Subdir `CLAUDE.md`** updated if a per-area rule / convention
changed
- [x] **`docs/adr/`** ADR added if an architectural / hard-to-reverse /
cross-component decision was made (sub-architectural choice →
**`docs/decisions.md`** note)
- [ ] **`docs/architecture/tech-debt.md`** entry removed if this PR
fixes a listed item, or added if this PR introduces new debt or defers a
follow-up
- [ ] None — this PR doesn't change any of the above

<!-- /pr render: sha=e5d70c03610b6df2f5fa8de1772816fcc57fc4d4
ts=2026-09-22T01:33:56Z -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Live TV: Jellyfin passes the container name as the audio codec, transcoding never starts

6 participants