6.29.0 - The tag that cost Atmos, and three ways a refusal was misread
Drop-in from 6.28.0. One additive API surface (PlaybackErrorKind.sourceRefused), which is why this is a minor rather than a patch, and no breaking changes. Everything else is a fix, and four of the five come from field traces on origins that push back.
The tag that cost Atmos
E-AC-3 out of an MPEG-TS could not stream-copy into the loopback fMP4. Every such session bridged, and on a JOC source that means the objects are re-encoded away: the receiver shows DD+ 5.1 where the channel carries Atmos.
The whole mechanism is one field nobody was looking at. mpegts stamps codecpar.codec_tag with the PMT stream type (0x87 for E-AC-3) or, when the PMT carries a registration descriptor, with its fourcc (EAC3). avcodec_parameters_copy carries that into the muxer's output stream, movenc looks the (tag, codec) pair up in the mp4 tag table, finds nothing, and refuses the whole header:
[mp4] Could not find tag for codec eac3 in stream #1, codec not currently supported in container
probeWriteHeader reports the -22 to the audio cascade, which can only read it as "this source cannot stream-copy", and bridges.
libavformat's own guard in init_muxer would have caught the foreign tag a layer earlier. It only fires at FF_COMPLIANCE_NORMAL, and the muxer runs at strict_std_compliance = -2 so it can write the Dolby Vision atoms; below NORMAL that guard passes a foreign tag through untouched. That is why an ffmpeg -c copy remux of the same source succeeds where this did not, and it is worth stating plainly: lowering a compliance level takes over the checks that hang on it.
The muxer now drops a source-container audio tag the mp4 muxer rejects, using the rule ffmpeg itself applies for stream copy (fftools/ffmpeg_mux_init.c, streamcopy_init): keep the tag when the output format has no tag table, when it maps back to the same codec id (already an mp4 tag, matched case-insensitively exactly as movenc validates it), or when mp4 knows no tag for the codec at all. Otherwise clear it and let init_muxer fill in the canonical one.
Audio only, deliberately. The video tag is chosen per route by the engine, and the same rule would be actively wrong there: dvh1 is not in the mp4 tag table at all, so a Dolby Vision sample entry would be silently demoted to hvc1.
Reproduced without the live path, on a file remux of the Dolby DD+/JOC delivery kit signal (ffmpeg -i ddp-joc.mp4 -c copy -f mpegts), which produced the reported log lines verbatim. So this was never live-specific. After the fix, the delivered init segment's dec3 box is byte-identical to the source MP4's, 1400200f000110: 640 kbps, 48 kHz, 5.1, flag_ec3_extension_type_a = 1, complexity index 16. The Atmos signalling reaches the sample entry, not just the bitstream.
AC-3 and AAC from MPEG-TS were measured on the same remux and escaped the defect already, for two unrelated reasons: an AC-3 registration descriptor resolves to ac-3 case-insensitively, and ADTS AAC has its tag cleared alongside the synthesised AudioSpecificConfig. Both now fall under one rule rather than two accidents.
Reported by Simpendaal (#382), with the correct diagnosis and the correct fix in the report.
A refused source is not a corrupt file
An origin answering 403 to every GET reached the host as sourceOpenFailed carrying FFmpeg's "Invalid data found when processing input", which is what a genuinely broken file produces. The forward-only streaming reader accepted the origin's error page as container bytes, so the demuxer probed HTML.
The reader now hangs up at the response header on anything but a 200/206 and fails the open typed with the status:
if info.kind == .sourceRefused {
// info.underlyingCode is the HTTP status: 401, 403, 404, 5xx.
// Credentials or entitlement, not media.
}A 429/503/509 on the same request publishes the existing sourceRateLimited instead, so the split the #377 contract asks a host to branch on now holds at the open too. Two smaller consequences fall out of the same trace: after a 401/403/404/410 on the open-time data connection the HEAD and bytes=0-1 size probes are skipped, so an origin that refuses Range but serves a plain GET plays forward-only while one that refuses both fails with its status; and the tail-prefetch "no suffix range support" latch is set only by an answer that says something about ranges (a 200 that ignored one, a 416 that rejected one), never by a refusal, which used to disable the prefetch for that origin for the rest of the process.
Reported by tschuegy, who also contributed the fix (#378, PR #379).
A pin that outlived its session, and the ladders a memory serve kept resetting
509 has two field shapes, and the keep-pin rule only knew one of them. The shape it was built for is a connection-capped panel refusing while the slot of the connection being replaced still lingers: it frees in seconds and the pin is fine. The shape it broke on is a resume after minutes of pause, where the reader holds no connection and the pinned edge target's session has expired server-side. That target then answers 509 forever, while a fresh redirect through the source connects on the first try. The field trace: 20 generations of 509 across about 85 s at one offset, then a source-resolved reader delivering first data in 452 ms.
The pin now survives three paced attempts and is then dropped for exactly one re-resolve; the fresh target's 200/206 re-pins, and a permanently metering origin pays the same bounded give-up as before.
Underneath it, three separate places reset the reconnect ladders on a read that never touched the network:
- the read-ahead window serve, in the same iteration as the faulted-refill decision, so a refused replacement was charged streak=1 for as long as the runway lasted,
- the retained head/tail spans, whose own log line says "no reconnect for it",
- the detour cache's resident-block hit, which could not tell a block it fetched from a block it already had.
Together they are the reported shape "one served byte reset the whole ladder and it started over": neither the re-resolve rung nor the bounded give-up was reachable. The streaks now reset only when the current generation has delivered data. The faulted-refill pacing also survives the reconnect it authorises, instead of being erased by it, and which target is pinned is now visible in release builds rather than only under #if DEBUG.
Reported by tschuegy, who also contributed the first fix (#380, PR #381).
A redirect chain is one origin
LoadOptions.maxConcurrentSourceRequests from 6.28.0 was registered for the origin of the URL the host loaded, and stopped there. On the shape it exists for, a portal that 302s to the media host counting the provider's connections, that is the wrong host: the pump followed the redirect and streamed from the target while holding a slot booked against the portal, so the first backward read (MKV cues at the tail, a non-faststart moov, a backward scrub) opened a detour block against a target whose books showed nothing in flight. The panel saw two requests where the host had declared one.
The host cannot route around it, since it only knows the URL it loads, and resolving the redirect before load would spend the one connection the panel allows. So the budget folds an observed redirect into one chain kept under the source's key: the ceiling covers the chain, the pump's existing ticket is already the chain's, and requiresSerialRequests is true at the pinned target from the first byte, so the detour falls back to the reposition path it already has.
Deliberate consequence, and the one place this departs from #377: a refusal now lowers the whole chain, portal included, because a request to the portal for this source is only ever answered with a redirect to the host that refused. A target that already belongs to a chain keeps it, so two portals on one edge host cannot spread a ceiling declared for one of them to the other.
Reported by tschuegy (#388).
The software clock parks at end of media
.ended stopped the demux loops and published the state, but left the software path's master synchronizer at rate 1. A finished session kept publishing a position that grew without bound (20.13 s on a 12.0 s source after 20 s, where a native session on the same file parks on 11.97 s and stays there), and the 1 Hz [SWDiag] line kept reporting an audio lead falling at exactly 1.00 per second, which is the shape of a session drifting rather than one that finished. Two readers, a downstream host and this repo, spent a round treating that as a suspected deinterlacer clock defect.
The clock now parks on the last sample, deferred by the audio still queued ahead of the playhead so the tail plays out rather than being cut, and the diagnostic names the exhaustion (eof=y) before falling silent on the tick that shows the clock parked.
Raised by ksktech-dev in the KSKPix integration thread (#374).
Not claimed
The Atmos fix was verified against a file remux and a byte comparison of the dec3 box, not against a credentialed live IPTV origin; the reporter's retest on the actual channel is still open. The #380 and #388 fixes were measured on loopback origins built to the reported shapes, which is the right tool for which requests happen in what order and the wrong one for how long a cold start takes on a real link.