Jelajahi Sumber

Harden HLS streaming and MSE recovery

Fixes multiple HLS playback issues across transcoder and Movie player flows. The transcoder now forces forward-slash HLS output paths so ffmpeg writes init.mp4 into the correct session directory on Windows, and adds NVENC `-forced-idr 1` to keep segment boundaries aligned with forced keyframes for responsive seeking.

On the frontend, the custom HLS MSE player now better mirrors native HLS behavior (playlist-length seekable range), adds segment retry/timeout logic, safer cursor retargeting on seek, append/trim coordination, and a stall watchdog with gap jumping and bounded recovery before surfacing failure. Movie pages now handle MSE stream-loss events explicitly, stop old media immediately when switching streams, and only show failure toasts after retry limits are exhausted.
Toby Chui 5 hari lalu
induk
melakukan
a0bdedbc44

+ 2 - 1
src/mediaServer.go

@@ -51,7 +51,8 @@ func mediaServer_init() {
 		//whose container extension merely looks playable
 		http.HandleFunc("/media/probe/", mediaServer.ServeMediaProbe)
 
-		//HLS output, for clients that require byte-range-able media (Safari / iOS)
+		//HLS output, for clients that require byte-range-able media (Safari / iOS);
+		//other browsers may opt in and play it through Media Source
 		http.HandleFunc("/media/hls/", mediaServer.ServeHLSPlaylist)
 		http.HandleFunc(mediaserver.HLSSegmentEndpoint, mediaServer.ServeHLSSegment)
 		http.HandleFunc("/media/duration/", mediaServer.GetAudioDuration)

+ 5 - 1
src/mod/media/mediaserver/hls.go

@@ -6,10 +6,14 @@ package mediaserver
 	HLS delivery endpoints for transcoded video.
 
 	This module adds support for HLS based streaming to the media server.
+	Safari (and every browser on iOS) needs it, since it will not play the
+	unbounded MP4 response of /media/transcode. Other browsers default to that
+	MP4 stream and can opt into HLS, which they play through Media Source
+	(web/script/hlsmse.js) because the segments are fragmented MP4.
 
 	Two endpoints make up the format:
 	  /media/hls/          ?file=<vpath>[&res=][&start=]  -> the .m3u8 playlist
-	  /media/hls/segment   ?sid=<session>&name=<segment>  -> one .ts segment
+	  /media/hls/segment   ?sid=<session>&name=<segment>  -> one fMP4 segment, or the init segment
 
 	The playlist request creates (or joins) a transcode session; every segment
 	line inside it points back at the segment endpoint carrying that session id.

+ 17 - 4
src/mod/media/transcoder/hls.go

@@ -194,7 +194,7 @@ func (s *HLSSession) stop() {
 }
 
 // validHLSSegmentName reports whether name matches the segment naming this
-// package generates ("seg00000.ts"), rejecting anything containing a path
+// package generates ("seg00000.m4s", plus the fixed init segment name), rejecting anything containing a path
 // separator, "..", or unexpected characters.
 func validHLSSegmentName(name string) bool {
 	// The fMP4 initialisation segment is fetched through the same endpoint as
@@ -269,7 +269,8 @@ func buildHLSArgs(inputFile string, dir string, resolution TranscodeOutputResolu
 
 	// Take the first video and, if present, the first audio track. Without this
 	// a file carrying extra streams (subtitles, attachments, second audio) can
-	// fail to mux into MPEG-TS.
+	// fail to mux into the fMP4 segments, or put a track the player does not
+	// expect into them.
 	args = append(args, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
 
 	if vf != "" {
@@ -298,12 +299,24 @@ func buildHLSArgs(inputFile string, dir string, resolution TranscodeOutputResolu
 		"-hls_segment_type", "fmp4",
 		"-hls_fmp4_init_filename", HLSInitSegmentName,
 		"-hls_base_url", segmentBaseURL,
-		"-hls_segment_filename", filepath.Join(dir, hlsSegmentPattern),
-		filepath.Join(dir, hlsPlaylistName),
+		"-hls_segment_filename", hlsOutputPath(dir, hlsSegmentPattern),
+		hlsOutputPath(dir, hlsPlaylistName),
 	)
 	return args, nil
 }
 
+// hlsOutputPath builds an output path for the HLS muxer with forward slashes.
+//
+// ffmpeg places the fMP4 init segment next to the playlist by cutting the
+// playlist path at its last '/', and only '/'. Given a native Windows path
+// (backslashes) it finds no directory, so init.mp4 lands in ffmpeg's working
+// directory instead of the session's, every session overwrites the same file,
+// and the player's first request - the init segment - 404s. ffmpeg on Windows
+// accepts forward slashes, and on every other OS this changes nothing.
+func hlsOutputPath(dir string, name string) string {
+	return filepath.ToSlash(filepath.Join(dir, name))
+}
+
 // resolutionHeight maps a requested output resolution to an ffmpeg scale
 // height, returning an error for anything unrecognised.
 func resolutionHeight(resolution TranscodeOutputResolution) (string, error) {

+ 20 - 1
src/mod/media/transcoder/hls_test.go

@@ -223,9 +223,20 @@ func TestBuildHLSArgs(t *testing.T) {
 		if strings.Contains(joined, "-ss ") {
 			t.Errorf("start time 0 should not add -ss\ngot: %v", args)
 		}
-		if last := args[len(args)-1]; last != filepath.Join(dir, hlsPlaylistName) {
+		if last := args[len(args)-1]; last != filepath.ToSlash(filepath.Join(dir, hlsPlaylistName)) {
 			t.Errorf("playlist path = %q, want it last and inside the session dir", last)
 		}
+		// ffmpeg derives the init segment's directory from the playlist path
+		// by its last '/', so a backslash there sends init.mp4 elsewhere
+		segmentFlag := indexOf(args, "-hls_segment_filename")
+		if segmentFlag == -1 {
+			t.Fatalf("args missing -hls_segment_filename\ngot: %v", args)
+		}
+		for _, path := range []string{args[segmentFlag+1], args[len(args)-1]} {
+			if strings.Contains(path, `\`) {
+				t.Errorf("output path %q must use forward slashes", path)
+			}
+		}
 	})
 
 	t.Run("hardware encoder", func(t *testing.T) {
@@ -595,6 +606,14 @@ func TestGetOrCreateSupersedesOnSeek(t *testing.T) {
 	if !strings.Contains(string(playlist), hlsSegmentSuffix) {
 		t.Error("the playlist lists no media segment")
 	}
+	//The segment endpoint serves the init segment from the session directory;
+	//on Windows ffmpeg used to drop it into its working directory instead
+	if _, err := second.SegmentPath(HLSInitSegmentName); err != nil {
+		t.Fatalf("init segment name rejected: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(second.Dir, HLSInitSegmentName)); err != nil {
+		t.Errorf("the init segment is not in the session directory: %v", err)
+	}
 }
 
 // generateTestVideo writes a short, deterministic clip with ffmpeg for the

+ 4 - 1
src/mod/media/transcoder/hwaccel_linux.go

@@ -21,7 +21,10 @@ func candidateHWProfiles() []*hwEncoderProfile {
 			Name:        "NVIDIA NVENC",
 			Codec:       "h264_nvenc",
 			ScaleFilter: nv12ScaleFilter,
-			EncodeArgs:  []string{"-preset", "fast"},
+			// -forced-idr: NVENC turns -force_key_frames into plain I-frames,
+			// which the HLS muxer cannot cut on, so segments fall back to its
+			// 250-frame GOP (~10s) and every seek has to wait for one.
+			EncodeArgs: []string{"-preset", "fast", "-forced-idr", "1"},
 		},
 		{
 			Name:     "Intel/AMD VAAPI",

+ 15 - 0
src/mod/media/transcoder/hwaccel_test.go

@@ -157,3 +157,18 @@ func TestGetHWEncoderProfile_Cached(t *testing.T) {
 		t.Errorf("getHWEncoderProfile() returned different results across calls: %v vs %v", first, second)
 	}
 }
+
+// TestNVENCProfilesForceIDR guards the HLS segment length: without -forced-idr
+// NVENC ignores -force_key_frames for segmenting purposes and cuts ~10s
+// segments at its default GOP. Platforms with no NVENC candidate pass trivially.
+func TestNVENCProfilesForceIDR(t *testing.T) {
+	for _, profile := range candidateHWProfiles() {
+		if profile.Codec != "h264_nvenc" {
+			continue
+		}
+		i := indexOf(profile.EncodeArgs, "-forced-idr")
+		if i == -1 || i+1 >= len(profile.EncodeArgs) || profile.EncodeArgs[i+1] != "1" {
+			t.Errorf("%s EncodeArgs = %v, want -forced-idr 1", profile.Name, profile.EncodeArgs)
+		}
+	}
+}

+ 4 - 1
src/mod/media/transcoder/hwaccel_windows.go

@@ -20,7 +20,10 @@ func candidateHWProfiles() []*hwEncoderProfile {
 			Name:        "NVIDIA NVENC",
 			Codec:       "h264_nvenc",
 			ScaleFilter: nv12ScaleFilter,
-			EncodeArgs:  []string{"-preset", "fast"},
+			// -forced-idr: NVENC turns -force_key_frames into plain I-frames,
+			// which the HLS muxer cannot cut on, so segments fall back to its
+			// 250-frame GOP (~10s) and every seek has to wait for one.
+			EncodeArgs: []string{"-preset", "fast", "-forced-idr", "1"},
 		},
 		{
 			Name:        "Intel Quick Sync (QSV)",

+ 47 - 5
src/web/Movie/backend/common.js

@@ -43,10 +43,22 @@ var SCRIPT_CLEAR_INDEX        = BACKEND_PATH + "clearIndex.js";
 //        is a finite, seekable file, so WebKit plays it and seeking inside the
 //        transcoded window no longer restarts the stream.
 //
-// "auto" picks hls on WebKit and mp4 everywhere else, so nothing changes for
-// browsers that were already working.
+// "auto" picks hls on WebKit and mp4 everywhere else (Firefox, Chrome, Edge on
+// Windows and Linux). HLS exists for WebKit's sake and is not the better choice
+// where MP4 works: the MP4 transcode writes into the response, so the client's
+// reading pace holds ffmpeg back and closing the player ends it, while an HLS
+// session writes the whole film to temp disk as fast as the CPU allows and
+// outlives the player until it is reaped. Other browsers can still choose HLS
+// in settings; they play it through Media Source (see nativeHLSSupported).
 var STREAM_MODE_KEY = "movie_stream_mode";
 
+// Fired on the <video> element when a Media Source HLS player loses a stream
+// it had already bound (session reaped, segments failing, playback stuck),
+// with event.detail.reason saying why. Pages treat it like the element's own
+// 'error' event, which is how native HLS reports the same thing, and reopen
+// the stream where it stopped.
+var STREAM_LOST_EVENT = "transcodestreamlost";
+
 // Identifies this player to the HLS endpoint. Seeking outside the transcoded
 // window restarts the transcode at a new offset, and the server has no other
 // way to tell that the previous one is finished with: the MP4 stream dies with
@@ -229,7 +241,9 @@ function playlistErrorMessage(body, status) {
 // A playlist is checked before the player is pointed at it, which makes the
 // attachment asynchronous: onReady fires once the stream is actually bound, and
 // is where the caller should call play(). onError reports a stream that never
-// became playable, with the server's own explanation.
+// became playable, with the server's own explanation. A stream that fails after
+// it was bound is reported on the element instead: its 'error' event for native
+// playback, STREAM_LOST_EVENT for the Media Source player.
 function attachTranscodeStream(videoEl, url, onError, onReady) {
     detachTranscodeStream(videoEl);
 
@@ -258,15 +272,33 @@ function attachTranscodeStream(videoEl, url, onError, onReady) {
         };
     } else if (window.MovieHLS && window.MovieHLS.isSupported()) {
         bind = function () {
-            videoEl._mseInstance = window.MovieHLS.attach(videoEl, url, {
-                onError: function (reason, err) { fail(reason, err); }
+            var instance = window.MovieHLS.attach(videoEl, url, {
+                onError: function (reason, err) {
+                    // A player already replaced by a newer stream is not news
+                    if (videoEl._mseInstance !== instance) { return; }
+                    videoEl.dispatchEvent(new CustomEvent(STREAM_LOST_EVENT, {
+                        detail: { reason: reason, error: err }
+                    }));
+                }
             });
+            videoEl._mseInstance = instance;
             ready();
         };
     } else {
         return false;
     }
 
+    // Stop the stream being replaced now, not when the new one binds. Tearing
+    // down its player does not stop the element: it plays on through whatever
+    // it had buffered while the page already counts time from the new offset,
+    // so after a jump to 2:20 the clock runs 2:20, 2:21, 2:22 over the old
+    // picture, then snaps back to 2:20 when the new stream arrives. The gap is
+    // the preflight below, which waits for the server to start ffmpeg at the
+    // new offset - seconds, not milliseconds. An emptied element shows nothing
+    // (the page keeps its freeze frame over it) and holds its clock at zero,
+    // which is exactly the new offset.
+    stopElementMedia(videoEl);
+
     // A seek made while the previous playlist is still being fetched must not
     // be overtaken by that older answer.
     var generation = (videoEl._streamGeneration || 0) + 1;
@@ -279,6 +311,16 @@ function attachTranscodeStream(videoEl, url, onError, onReady) {
     return true;
 }
 
+// Empty the element so nothing keeps playing from its old source. Removing the
+// attribute (rather than setting src to "") matters: an empty src is an invalid
+// source and fires 'error', which the pages would treat as a lost stream. The
+// load() rejects a pending play() with AbortError, which playVideo ignores.
+function stopElementMedia(videoEl) {
+    if (!videoEl.hasAttribute("src")) { return; }
+    videoEl.removeAttribute("src");
+    videoEl.load();
+}
+
 // Release whichever player is currently bound to the element. Always call this
 // before pointing a <video> somewhere new, or an MSE attachment keeps feeding
 // segments into an element that has moved on.

+ 17 - 4
src/web/Movie/embedded.html

@@ -1074,7 +1074,9 @@ function transcodeSeekTo(seconds) {
 }
 
 // Whether `pos` (absolute time) falls inside what the current stream has
-// already produced and can therefore be reached without reloading.
+// already produced and can therefore be reached without reloading. seekable
+// spans every segment the playlist lists, for native HLS and for the Media
+// Source player alike (it publishes the playlist's length as the duration).
 function seekWithinTranscodedWindow(videoEl, pos) {
     var relative = pos - transcodeSeekOffset;
     if (relative < 0) { return false; }
@@ -1090,15 +1092,19 @@ function seekWithinTranscodedWindow(videoEl, pos) {
 // player: reaped after a long pause, retired when the same player asked for a
 // different offset, or lost with a restart. The next segment then 404s and the
 // element reports a decode error, which leaves a black frame and no way back.
-// Re-open the stream where it stopped instead.
+// Re-open the stream where it stopped instead. The Media Source player reports
+// the same failures as STREAM_LOST_EVENT rather than an element error.
+//
+// Returns whether a recovery was started, so a caller holding a reason for the
+// failure can show it once recovery has given up.
 var streamRecoveryAttempts = 0;
 var STREAM_RECOVERY_LIMIT  = 3;   // consecutive tries before giving up
 
 function recoverTranscodeStream() {
-    if (!isTranscodedVideo || !usingHLS() || !currentFile) { return; }
+    if (!isTranscodedVideo || !usingHLS() || !currentFile) { return false; }
     // A file that genuinely cannot be transcoded fails every time; retrying it
     // forever would replace one dead player with a flickering one.
-    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return; }
+    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return false; }
     streamRecoveryAttempts++;
 
     var resumeAt = effectivePlaybackTime();
@@ -1107,6 +1113,7 @@ function recoverTranscodeStream() {
     attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, resumeAt),
         function (reason) { hideSeekFreeze(); showToast(reason); },
         function () { playVideo(vid); });
+    return true;
 }
 
 // Current playback position in whole-file terms (transcode streams restart at 0)
@@ -1374,6 +1381,12 @@ function initVideoControls() {
         hideSeekFreeze();
         recoverTranscodeStream();
     });
+    $(vid).on(STREAM_LOST_EVENT, function (e) {
+        hideSeekFreeze();
+        if (recoverTranscodeStream()) { return; }
+        var detail = (e.originalEvent && e.originalEvent.detail) || {};
+        showToast(detail.reason || 'The HLS stream stopped and could not be reopened.');
+    });
 
     // Auto-hide controls on mouse movement
     $('#player-wrap').on('mousemove touchstart', showControls);

+ 18 - 5
src/web/Movie/index.html

@@ -2680,7 +2680,9 @@ function transcodeSeekTo(seconds) {
 }
 
 // Whether `pos` (absolute time) falls inside what the current stream has
-// already produced and can therefore be reached without reloading.
+// already produced and can therefore be reached without reloading. seekable
+// spans every segment the playlist lists, for native HLS and for the Media
+// Source player alike (it publishes the playlist's length as the duration).
 function seekWithinTranscodedWindow(vid, pos) {
     var relative = pos - transcodeSeekOffset;
     if (relative < 0) { return false; }
@@ -2696,16 +2698,20 @@ function seekWithinTranscodedWindow(vid, pos) {
 // player: reaped after a long pause, retired when the same player asked for a
 // different offset, or lost with a restart. The next segment then 404s and the
 // element reports a decode error, which leaves a black frame and no way back.
-// Re-open the stream where it stopped instead.
+// Re-open the stream where it stopped instead. The Media Source player reports
+// the same failures as STREAM_LOST_EVENT rather than an element error.
+//
+// Returns whether a recovery was started, so a caller holding a reason for the
+// failure can show it once recovery has given up.
 var streamRecoveryAttempts = 0;
 var STREAM_RECOVERY_LIMIT  = 3;   // consecutive tries before giving up
 
 function recoverTranscodeStream() {
-    if (!isTranscodedVideo || !usingHLS()) { return; }
-    if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
+    if (!isTranscodedVideo || !usingHLS()) { return false; }
+    if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return false; }
     // A file that genuinely cannot be transcoded fails every time; retrying it
     // forever would replace one dead player with a flickering one.
-    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return; }
+    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return false; }
     streamRecoveryAttempts++;
 
     var vid      = document.getElementById('main-video');
@@ -2715,6 +2721,7 @@ function recoverTranscodeStream() {
     attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, resumeAt),
         function (reason) { hideSeekFreeze(); showToast(reason); },
         function () { playVideo(vid); });
+    return true;
 }
 
 // Current playback position in whole-file terms (transcode streams restart at 0)
@@ -3906,6 +3913,12 @@ function initVideoControls() {
         hideSeekFreeze();
         recoverTranscodeStream();
     });
+    $(vid).on(STREAM_LOST_EVENT, function (e) {
+        hideSeekFreeze();
+        if (recoverTranscodeStream()) { return; }
+        var detail = (e.originalEvent && e.originalEvent.detail) || {};
+        showToast(detail.reason || 'The HLS stream stopped and could not be reopened.');
+    });
 
     // Auto-hide controls
     $('#video-container').on('mousemove touchstart', function () { showControls(); });

+ 310 - 32
src/web/script/hlsmse.js

@@ -18,6 +18,23 @@
       • Codec detection read from the init segment, so the SourceBuffer is
         created with the stream's real profile rather than a guess
 
+    Behaving like Safari's native player
+      The pages using this treat it as a drop-in for native HLS, so it keeps
+      the same contract: video.seekable spans every segment the playlist lists
+      (not just what has been downloaded), which is how a page tells a seek it
+      can make in place from one that needs the transcode restarted.
+
+    Recovery
+      • A segment download that fails on the network or with a server error
+        is retried with backoff; a 404/403 is not, since it means the server
+        session is gone and only reopening the stream helps.
+      • A watchdog steps over small holes between segments, and points the
+        download cursor back at the playhead if its media is neither buffered
+        nor on the way.
+      • Anything it cannot recover from is reported once through
+        options.onError, after which the player stops. The caller is expected
+        to reopen the stream (the Movie app restarts the transcode there).
+
     Not supported (by design — the server never produces them)
       • Master playlists / multiple variants / bitrate switching
       • MPEG-TS segments, encryption, discontinuities, subtitle renditions
@@ -29,6 +46,23 @@
 var BUFFER_AHEAD_SECONDS = 30;
 // How much already-played media to keep before trimming it out of the buffer.
 var BUFFER_BEHIND_SECONDS = 30;
+// Retries after a failed segment download, the first waiting RETRY_DELAY_MS
+// and each one after that twice as long as the one before.
+var SEGMENT_RETRIES = 3;
+var RETRY_DELAY_MS = 500;
+// A download with no answer by then is abandoned and retried. A server
+// segment is a few seconds of video, so this only trips on a hung connection.
+var SEGMENT_TIMEOUT_MS = 20000;
+// How often the stall watchdog looks at the element.
+var STALL_CHECK_MS = 1000;
+// The largest hole between buffered ranges the watchdog skips over. Holes this
+// small come from audio and video starting a few frames apart at a segment
+// boundary; they never fill in, and some browsers will not play across them.
+var GAP_TOLERANCE_SECONDS = 1;
+// How long playback may sit stuck on media the playlist already lists before
+// the player gives up and reports it. Waiting on a transcode that has not yet
+// produced the next segment never counts towards this.
+var STALL_FAIL_MS = 30000;
 // Fallback codecs when the init segment cannot be parsed: the server always
 // encodes H.264 High + AAC-LC, so this is the right shape even if the exact
 // profile digits differ.
@@ -159,19 +193,40 @@ function Player(videoEl, playlistURL, options) {
     this.playlistURL = playlistURL;
     this.options = options || {};
     this.destroyed = false;
+    this.failed = false;
 
     this.mediaSource = null;
     this.sourceBuffer = null;
     this.playlist = null;
     this.initBuffer = null;
-    this.nextIndex = 0;
-    this.appending = false;
     this.refreshTimer = null;
     this.objectURL = null;
 
+    // Download cursor. nextIndex is the next segment to fetch; a segment in
+    // flight is recorded separately, so a seek landing mid-download can move
+    // the cursor without the finished download moving it again.
+    this.nextIndex = 0;
+    this.appending = false;
+    this.inflightIndex = -1;
+    this.inflightAbort = null;
+    // Bumped every time the cursor is moved by something other than a
+    // download completing. A download started under an older generation is
+    // discarded when it lands.
+    this.cursorGeneration = 0;
+    // A segment that arrived while the buffer was busy (removing, or the
+    // previous append still running), appended on the next updateend.
+    this.pendingAppend = null;
+
+    // Stall watchdog state
+    this.stallTicks = 0;
+    this.healedIndex = -1;
+    this.watchdogTimer = null;
+
     this._onSourceOpen = this._onSourceOpen.bind(this);
     this._pump = this._pump.bind(this);
     this._onSeeking = this._onSeeking.bind(this);
+    this._onUpdateEnd = this._onUpdateEnd.bind(this);
+    this._checkStall = this._checkStall.bind(this);
 
     this.mediaSource = new global.MediaSource();
     this.objectURL = URL.createObjectURL(this.mediaSource);
@@ -179,10 +234,16 @@ function Player(videoEl, playlistURL, options) {
     this.video.addEventListener('timeupdate', this._pump);
     this.video.addEventListener('seeking', this._onSeeking);
     this.video.src = this.objectURL;
+    this.watchdogTimer = setInterval(this._checkStall, STALL_CHECK_MS);
 }
 
+// Report an unrecoverable failure, once. The player stops working after this:
+// the caller is expected to tear it down and reopen the stream.
 Player.prototype._fail = function (reason, err) {
-    if (this.destroyed) { return; }
+    if (this.destroyed || this.failed) { return; }
+    this.failed = true;
+    clearInterval(this.watchdogTimer);
+    clearTimeout(this.refreshTimer);
     if (typeof this.options.onError === 'function') { this.options.onError(reason, err); }
 };
 
@@ -206,10 +267,11 @@ Player.prototype._onSourceOpen = function () {
             }
 
             self.sourceBuffer = self.mediaSource.addSourceBuffer(mime);
-            self.sourceBuffer.addEventListener('updateend', self._pump);
+            self.sourceBuffer.addEventListener('updateend', self._onUpdateEnd);
             self.sourceBuffer.addEventListener('error', function () {
                 self._fail('The browser rejected a media segment');
             });
+            self._syncDuration();
             self._append(buffer);
         });
     }).catch(function (err) {
@@ -217,11 +279,66 @@ Player.prototype._onSourceOpen = function () {
     });
 };
 
-Player.prototype._fetch = function (url) {
-    return fetch(url, { credentials: 'same-origin' }).then(function (response) {
-        if (!response.ok) { throw new Error('HTTP ' + response.status + ' for ' + url); }
-        return response.arrayBuffer();
-    });
+Player.prototype._onUpdateEnd = function () {
+    if (this.destroyed) { return; }
+    if (this.pendingAppend) {
+        var buffer = this.pendingAppend;
+        this.pendingAppend = null;
+        this._append(buffer);
+        return;
+    }
+    // Setting the duration is refused while the buffer is updating, so a
+    // playlist refresh that arrived meanwhile is applied here
+    this._syncDuration();
+    this._pump();
+};
+
+// Fetch one segment (or the init segment), retrying transient failures.
+//
+// isCurrent is checked before every retry; once it returns false (a seek has
+// moved the cursor elsewhere) the download is abandoned instead of retried.
+Player.prototype._fetch = function (url, isCurrent) {
+    var self = this;
+    var attempt = 0;
+
+    function tryOnce() {
+        var controller = global.AbortController ? new global.AbortController() : null;
+        var timedOut = false;
+        var timer = null;
+        if (controller) {
+            self.inflightAbort = controller;
+            timer = setTimeout(function () { timedOut = true; controller.abort(); }, SEGMENT_TIMEOUT_MS);
+        }
+
+        return fetch(url, { credentials: 'same-origin', signal: controller ? controller.signal : undefined })
+            .then(function (response) {
+                if (!response.ok) {
+                    var httpErr = new Error('HTTP ' + response.status + ' for ' + url);
+                    // 404/403 mean the session behind this stream is gone or
+                    // was never ours: asking again cannot change the answer
+                    httpErr.retryable = response.status >= 500;
+                    throw httpErr;
+                }
+                return response.arrayBuffer();
+            })
+            .then(function (buffer) {
+                clearTimeout(timer);
+                return buffer;
+            }, function (err) {
+                clearTimeout(timer);
+                if (self.destroyed || (isCurrent && !isCurrent())) { throw err; }
+                // An abort that was not the timeout came from a seek or teardown
+                if (err && err.name === 'AbortError' && !timedOut) { throw err; }
+                if ((err && err.retryable === false) || attempt >= SEGMENT_RETRIES) { throw err; }
+                var wait = RETRY_DELAY_MS * Math.pow(2, attempt);
+                attempt++;
+                return new Promise(function (resolve) { setTimeout(resolve, wait); }).then(function () {
+                    if (self.destroyed || (isCurrent && !isCurrent())) { throw err; }
+                    return tryOnce();
+                });
+            });
+    }
+    return tryOnce();
 };
 
 Player.prototype._loadPlaylist = function () {
@@ -234,6 +351,7 @@ Player.prototype._loadPlaylist = function () {
         .then(function (text) {
             if (self.destroyed) { return; }
             self.playlist = parsePlaylist(text, self.playlistURL);
+            self._syncDuration();
             self._scheduleRefresh();
         });
 };
@@ -243,9 +361,15 @@ Player.prototype._loadPlaylist = function () {
 Player.prototype._scheduleRefresh = function () {
     var self = this;
     clearTimeout(this.refreshTimer);
-    if (this.destroyed || !this.playlist || this.playlist.ended) { return; }
-
-    var wait = Math.max(1000, (this.playlist.targetDuration || 4) * 500);
+    if (this.destroyed || this.failed || !this.playlist || this.playlist.ended) { return; }
+
+    // Half a segment, but never more than two seconds. The page decides whether
+    // a seek can be served in place from how far this playlist reaches, and
+    // the first copy lists a single segment (the server answers as soon as one
+    // exists). With a hardware encoder that ignores forced keyframes, segments
+    // run to 10s or more, and a stale playlist turns seeks into needless
+    // transcode restarts.
+    var wait = Math.max(1000, Math.min(2000, (this.playlist.targetDuration || 4) * 500));
     this.refreshTimer = setTimeout(function () {
         if (self.destroyed) { return; }
         self._loadPlaylist().then(function () { self._pump(); })
@@ -253,6 +377,29 @@ Player.prototype._scheduleRefresh = function () {
     }, wait);
 };
 
+// Publish the playlist's length as the media duration.
+//
+// Media Source reports seekable as 0..duration, and left alone the duration
+// only ever reaches the end of what has been appended — about half a minute
+// past the playhead. A page asking "has the transcode got this far?" would
+// then be told no for segments that exist, and restart ffmpeg for nothing.
+// Using the playlist's length gives the same answer Safari's native player
+// does for an EVENT playlist: everything the transcode has produced so far.
+Player.prototype._syncDuration = function () {
+    var ms = this.mediaSource;
+    if (!ms || ms.readyState !== 'open' || !this.playlist) { return; }
+    // Refused while an append or removal is running; retried on updateend
+    if (this.sourceBuffer && this.sourceBuffer.updating) { return; }
+
+    var target = this.playlist.duration;
+    if (!(target > 0)) { return; }
+    // Only ever grow it. Shrinking below the buffered media is refused, and a
+    // finished stream's endOfStream() settles it a fraction under the
+    // playlist's rounded total, which is not worth fighting over.
+    if (!isNaN(ms.duration) && target <= ms.duration + 0.5) { return; }
+    try { ms.duration = target; } catch (e) { /* retried on the next refresh */ }
+};
+
 Player.prototype._bufferedAhead = function () {
     var buffered = this.video.buffered;
     var time = this.video.currentTime;
@@ -264,6 +411,28 @@ Player.prototype._bufferedAhead = function () {
     return 0;
 };
 
+// Whether there is media to play at `time`, with a little room after it.
+Player.prototype._isBufferedAt = function (time) {
+    var buffered = this.video.buffered;
+    for (var i = 0; i < buffered.length; i++) {
+        if (buffered.start(i) <= time && time < buffered.end(i) - 0.1) { return true; }
+    }
+    return false;
+};
+
+// Whether a segment's whole span is already in the buffer. The edges are
+// allowed some slack: audio and video rarely start and end on exactly the
+// segment's nominal boundary.
+Player.prototype._isSegmentBuffered = function (segment) {
+    var buffered = this.video.buffered;
+    var start = segment.start + 0.2;
+    var end = segment.start + segment.duration - 0.2;
+    for (var i = 0; i < buffered.length; i++) {
+        if (buffered.start(i) <= start && end <= buffered.end(i)) { return true; }
+    }
+    return false;
+};
+
 Player.prototype._segmentIndexForTime = function (time) {
     var segments = this.playlist ? this.playlist.segments : [];
     for (var i = 0; i < segments.length; i++) {
@@ -272,31 +441,50 @@ Player.prototype._segmentIndexForTime = function (time) {
     return segments.length;
 };
 
+// Point the download cursor at `index`, abandoning whatever download is in
+// flight. The abandoned download's result is dropped when it lands (see the
+// generation check in _pump), so it can neither be appended out of turn nor
+// move the cursor off the segment that is now wanted.
+Player.prototype._retarget = function (index) {
+    this.cursorGeneration++;
+    if (this.inflightAbort) {
+        try { this.inflightAbort.abort(); } catch (e) {}
+        this.inflightAbort = null;
+    }
+    this.appending = false;
+    this.inflightIndex = -1;
+    this.nextIndex = index;
+    this._pump();
+};
+
 Player.prototype._onSeeking = function () {
     if (this.destroyed || !this.playlist || !this.sourceBuffer) { return; }
     var target = this.video.currentTime;
 
     // Already buffered around the target: let the browser play it.
-    var buffered = this.video.buffered;
-    for (var i = 0; i < buffered.length; i++) {
-        if (buffered.start(i) <= target && target < buffered.end(i) - 0.1) { return; }
-    }
+    if (this._isBufferedAt(target)) { return; }
 
-    // Otherwise restart the append cursor at the segment covering the target.
-    this.nextIndex = this._segmentIndexForTime(target);
-    this._pump();
+    // Otherwise restart the download cursor at the segment covering the target.
+    this._retarget(this._segmentIndexForTime(target));
 };
 
 // Drive downloads: keep a window buffered ahead of the playhead, and trim what
 // is far behind so a long session does not grow without bound.
 Player.prototype._pump = function () {
     var self = this;
-    if (this.destroyed || !this.sourceBuffer || this.sourceBuffer.updating || this.appending) { return; }
+    if (this.destroyed || this.failed || !this.sourceBuffer || this.sourceBuffer.updating || this.appending) { return; }
     if (!this.playlist) { return; }
 
     this._trimBehind();
 
-    if (this.nextIndex >= this.playlist.segments.length) {
+    // After a backward seek the cursor walks forward over segments that are
+    // still buffered from before; fetching those again only costs bandwidth.
+    var segments = this.playlist.segments;
+    while (this.nextIndex < segments.length && this._isSegmentBuffered(segments[this.nextIndex])) {
+        this.nextIndex++;
+    }
+
+    if (this.nextIndex >= segments.length) {
         if (this.playlist.ended && this.mediaSource.readyState === 'open') {
             try { this.mediaSource.endOfStream(); } catch (e) {}
         }
@@ -304,53 +492,143 @@ Player.prototype._pump = function () {
     }
     if (this._bufferedAhead() > BUFFER_AHEAD_SECONDS) { return; }
 
-    var segment = this.playlist.segments[this.nextIndex];
+    var index = this.nextIndex;
+    var generation = this.cursorGeneration;
+    var isCurrent = function () { return generation === self.cursorGeneration; };
+
     this.appending = true;
-    this._fetch(segment.url).then(function (buffer) {
+    this.inflightIndex = index;
+    this._fetch(segments[index].url, isCurrent).then(function (buffer) {
+        // A seek moved the cursor while this was downloading. It has already
+        // cleared the in-flight state and started the download it wants.
+        if (!isCurrent()) { return; }
         self.appending = false;
+        self.inflightIndex = -1;
+        self.inflightAbort = null;
         if (self.destroyed || !self.sourceBuffer) { return; }
-        self.nextIndex++;
+        self.nextIndex = index + 1;
         self._append(buffer);
     }).catch(function (err) {
+        if (!isCurrent()) { return; }
         self.appending = false;
+        self.inflightIndex = -1;
+        self.inflightAbort = null;
         self._fail('A media segment failed to load', err);
     });
 };
 
 Player.prototype._append = function (buffer) {
     if (this.destroyed || !this.sourceBuffer) { return; }
+    if (this.sourceBuffer.updating) {
+        // A removal started by the trim is still running; append once it ends
+        this.pendingAppend = buffer;
+        return;
+    }
     try {
         this.sourceBuffer.appendBuffer(new Uint8Array(buffer));
     } catch (err) {
-        // A full buffer is recoverable: drop what is behind and retry once.
-        if (err && err.name === 'QuotaExceededError') {
-            this._trimBehind(true);
-            try { this.sourceBuffer.appendBuffer(new Uint8Array(buffer)); return; } catch (e) {}
+        // A full buffer is recoverable: drop what is behind and retry once the
+        // removal has finished.
+        if (err && err.name === 'QuotaExceededError' && this._trimBehind(true)) {
+            this.pendingAppend = buffer;
+            return;
         }
         this._fail('The browser rejected a media segment', err);
     }
 };
 
+// Remove media far behind the playhead. Returns true when a removal was
+// started (the buffer is then updating until the next updateend).
 Player.prototype._trimBehind = function (aggressive) {
-    if (!this.sourceBuffer || this.sourceBuffer.updating) { return; }
+    if (!this.sourceBuffer || this.sourceBuffer.updating) { return false; }
     var keep = aggressive ? 5 : BUFFER_BEHIND_SECONDS;
     var cutoff = this.video.currentTime - keep;
-    if (cutoff <= 0) { return; }
+    if (cutoff <= 0) { return false; }
 
     var buffered = this.sourceBuffer.buffered;
-    if (!buffered.length) { return; }
+    if (!buffered.length) { return false; }
     if (buffered.start(0) < cutoff) {
-        try { this.sourceBuffer.remove(buffered.start(0), cutoff); } catch (e) {}
+        try { this.sourceBuffer.remove(buffered.start(0), cutoff); return true; } catch (e) {}
+    }
+    return false;
+};
+
+// Step over a small hole just ahead of the playhead. Returns true if it moved
+// the playhead.
+Player.prototype._jumpGap = function (time) {
+    if (this._isBufferedAt(time)) { return false; }
+    var buffered = this.video.buffered;
+    for (var i = 0; i < buffered.length; i++) {
+        var start = buffered.start(i);
+        if (start > time && start - time <= GAP_TOLERANCE_SECONDS) {
+            this.video.currentTime = start + 0.05;
+            return true;
+        }
+    }
+    return false;
+};
+
+// Runs every STALL_CHECK_MS. Playback that cannot move on is nudged past a
+// small hole first, then has its missing media fetched again, and is only
+// reported as failed once neither has helped for STALL_FAIL_MS.
+Player.prototype._checkStall = function () {
+    if (this.destroyed || this.failed || !this.sourceBuffer || !this.playlist) { return; }
+    var video = this.video;
+
+    // HAVE_FUTURE_DATA or better: the element can move on by itself
+    if (video.ended || video.readyState >= 3) {
+        this.stallTicks = 0;
+        this.healedIndex = -1;
+        return;
+    }
+    this.stallTicks++;
+    // One quiet tick is normal: the next segment is often mid-append
+    if (this.stallTicks < 2) { return; }
+
+    var time = video.currentTime;
+    if (this._jumpGap(time)) { return; }
+
+    var index = this._segmentIndexForTime(time);
+    if (index >= this.playlist.segments.length) {
+        // Waiting on the transcode to produce the next segment. Nothing is
+        // wrong on this side, so none of this counts as a stall.
+        this.stallTicks = 0;
+        return;
+    }
+
+    // The media for the playhead is neither buffered nor being downloaded.
+    // Whatever left the cursor elsewhere, bring it back — once per segment,
+    // so a segment that genuinely cannot fill the hole does not loop.
+    var onItsWay = (this.appending && this.inflightIndex === index) ||
+        this.pendingAppend !== null || this.sourceBuffer.updating;
+    if (!onItsWay && !this._isBufferedAt(time) && this.healedIndex !== index) {
+        this.healedIndex = index;
+        this._retarget(index);
+        return;
+    }
+
+    // A download in flight has its own timeout and retries, and a paused
+    // viewer is not waiting on anything
+    if (onItsWay || video.paused) { return; }
+    if (this.stallTicks * STALL_CHECK_MS >= STALL_FAIL_MS) {
+        this._fail('Playback stalled and could not recover');
     }
 };
 
 Player.prototype.destroy = function () {
     this.destroyed = true;
     clearTimeout(this.refreshTimer);
+    clearInterval(this.watchdogTimer);
     this.video.removeEventListener('timeupdate', this._pump);
     this.video.removeEventListener('seeking', this._onSeeking);
+    if (this.inflightAbort) {
+        try { this.inflightAbort.abort(); } catch (e) {}
+        this.inflightAbort = null;
+    }
+    this.pendingAppend = null;
 
     if (this.sourceBuffer) {
+        this.sourceBuffer.removeEventListener('updateend', this._onUpdateEnd);
         try { this.sourceBuffer.abort(); } catch (e) {}
         this.sourceBuffer = null;
     }