HLS Playlist Accuracy: Why Safari iOS Broke and Other Players Didn't
The Symptom
A specific video played fine on desktop browsers, Android, and VLC — but Safari iOS showed a black screen and refused to play. The HLS playlist had two problems:
EXTINFdurations were rounded (e.g.,#EXTINF:2.000,instead of#EXTINF:2.048,), causing the sum of segment durations to violate theTARGETDURATIONceiling.- Missing
#EXT-X-DISCONTINUITYtags where the segment sequence had gaps.
Most players tolerate these violations. Safari iOS enforces the HLS spec strictly. It’s the canary.
The Root Cause: A Boolean That Should Have Been a Struct
The download pipeline validates every segment after downloading it:
validateSegment(filePath: string): Promise<boolean>;
Tango and FC2 providers implemented this by calling ffprobe (MediaValidator.getMediaInfo), which returns bitrate, resolution, and duration. The method checked those values, then returned true or false — discarding the duration.
The playlist was then built using the provider’s EXTINF metadata from the live playlist, which has rounded durations. The accurate ffprobe’d duration was computed, used for validation, and thrown away.
// Before: accurate duration computed and discarded
public async validateSegment(filePath: string): Promise<boolean> {
const info = await MediaValidator.getMediaInfo(filePath);
if (!info) return false;
if (isNaN(info.bitRate) || info.bitRate < 1000) return false;
if (!isNaN(info.duration) && info.duration > 3600) return false;
return true; // info.duration is gone
}
The Fix
Widen the return type to carry the duration through:
validateSegment(filePath: string): Promise<{ valid: boolean; duration?: number }>;
Providers that already run ffprobe (Tango, FC2) return the duration they already computed. Providers that don’t (SC) return just { valid } — SC’s durations come from FFmpeg and are already accurate.
The pipeline passes the duration to the playlist manager, which overwrites the rounded EXTINF:
if (segment.accurateDuration !== undefined && segment.accurateDuration > 0) {
const idx = segment.metadata.findIndex(l => l.startsWith("#EXTINF:"));
if (idx !== -1) {
segment.metadata[idx] = `#EXTINF:${segment.accurateDuration.toFixed(3)},`;
}
}
No new ffprobe calls. No extra CPU. The data was already there.
The Hack That Masked It
There was a TARGETDURATION +1 bump in the playlist header:
// BUMP TARGET DURATION: Intercept and increase by 1s
if (line.startsWith("#EXT-X-TARGETDURATION:")) {
const originalDuration = parseInt(line.split(":")[1], 10);
return `#EXT-X-TARGETDURATION:${originalDuration + 1}`;
}
This papered over the rounding issue for most segments but couldn’t fix it reliably — a segment rounded from 2.048 to 2.000 might not trigger a violation, but one rounded from 5.8 to 5.0 with a TARGETDURATION of 5 would. And it did nothing about missing discontinuity tags.
With accurate durations flowing through, fixTargetDuration in finalizePlaylist (which scans actual EXTINF values and corrects the header) handles any remaining edge cases. The +1 hack was removed.
The Second Bug: Non-Numeric Segment Names
The server has a generatePlaylist fallback that rebuilds a playlist from .ts files on disk when no playlist exists. It sorted and detected gaps by parsing the filename as a number:
const numA = parseInt(a.replace(".ts", ""), 10); // "42" -> 42
SC segments are named sc_local_..._segment_000.ts. parseInt("sc_local_...") returns NaN, breaking both the sort order and gap detection (every segment looked like a discontinuity).
The fix extracts the trailing number with a regex:
function extractSegmentNumber(filename: string): number | null {
const match = filename.match(/(\d+)\.ts$/);
return match ? parseInt(match[1], 10) : null;
}
Sort falls back to alphabetical for non-numeric names. Gap detection skips the discontinuity check when either segment has no extractable number.
Takeaways
Don’t discard computed data. When expensive work is already being done (ffprobe runs on every segment for validation), surface all useful results through the interface. A boolean return type was a premature narrowing that created a downstream problem months later.
Don’t patch spec violations with arithmetic hacks. The +1 bump was a band-aid that masked the real issue and couldn’t cover all cases. Fixing the source data made it unnecessary.
Test on Safari iOS. It’s the strictest mainstream HLS implementation. If it plays there, the playlist is correct. Every other player will forgive spec violations that Safari won’t.