Repository: streamlink/streamlink
Pull request #3404 (2020-12-09 15:08:25)

add ffmpeg-copyts option
`hls-multi` streams occasionally have an input offset between the audio and video streams and the sound is then out of sync. With `copyts`, the offset is not changed when outputting to the player so that the sound is in sync.


Patch:
diff --git a/src/streamlink/plugin/api/useragents.py b/src/streamlink/plugin/api/useragents.py
index 46187bd3..36db88b8 100644
--- a/src/streamlink/plugin/api/useragents.py
+++ b/src/streamlink/plugin/api/useragents.py
@@ -1,12 +1,12 @@
-ANDROID = "Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) 87.0.4280.66 Mobile Safari/537.36"
-CHROME = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) 87.0.4280.66 Safari/537.36"
-CHROME_OS = "Mozilla/5.0 (X11; CrOS armv7l 13421.99.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36"
-EDGE = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36 Edg/86.0.622.68"
-FIREFOX = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
+ANDROID = "Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) 87.0.4280.88 Mobile Safari/537.36"
+CHROME = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
+CHROME_OS = "Mozilla/5.0 (X11; CrOS armv7l 13421.99.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
+EDGE = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60"
+FIREFOX = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0"
 IE_11 = "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko"
 IPAD = "Mozilla/5.0 (iPad; CPU OS 14_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"
 IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"
-OPERA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36 OPR/72.0.3815.378"
+OPERA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.67 Safari/537.36 OPR/73.0.3856.260"
 SAFARI = "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15"
 
 # Backwards compatibility
diff --git a/src/streamlink/session.py b/src/streamlink/session.py
index 1338e6c8..2183c034 100644
--- a/src/streamlink/session.py
+++ b/src/streamlink/session.py
@@ -60,6 +60,7 @@ class Streamlink:
             "ffmpeg-fout": None,
             "ffmpeg-video-transcode": None,
             "ffmpeg-audio-transcode": None,
+            "ffmpeg-copyts": False,
             "ffmpeg-start-at-zero": False,
             "mux-subtitles": False,
             "locale": None,
@@ -199,6 +200,8 @@ class Streamlink:
                                  audio when muxing with ffmpeg
                                  e.g. ``aac``
 
+        ffmpeg-copyts            (bool) When used with ffmpeg, do not shift input timestamps.
+
         ffmpeg-start-at-zero     (bool) When used with ffmpeg and copyts,
                                  shift input timestamps so they start at zero
                                  default: ``False``
diff --git a/src/streamlink/stream/ffmpegmux.py b/src/streamlink/stream/ffmpegmux.py
index 9e740cb5..aa319442 100644
--- a/src/streamlink/stream/ffmpegmux.py
+++ b/src/streamlink/stream/ffmpegmux.py
@@ -98,7 +98,7 @@ class FFMPEGMuxer(StreamIO):
         audiocodec = session.options.get("ffmpeg-audio-transcode") or options.pop("acodec", self.DEFAULT_AUDIO_CODEC)
         metadata = options.pop("metadata", {})
         maps = options.pop("maps", [])
-        copyts = options.pop("copyts", False)
+        copyts = session.options.get("ffmpeg-copyts") or options.pop("copyts", False)
         start_at_zero = session.options.get("ffmpeg-start-at-zero") or options.pop("start_at_zero", False)
 
         self._cmd = [self.command(session), '-nostats', '-y']
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 6c5c61d4..2ba9cc8e 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -1133,6 +1133,14 @@ def build_parser():
         Example: "aac"
         """
     )
+    transport.add_argument(
+        "--ffmpeg-copyts",
+        action="store_true",
+        help="""
+        Forces the -copyts ffmpeg option and does not remove
+        the initial start time offset value.
+        """
+    )
     transport.add_argument(
         "--ffmpeg-start-at-zero",
         action="store_true",
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 8848d42e..2b807b1f 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -849,6 +849,8 @@ def setup_options():
         streamlink.set_option("ffmpeg-video-transcode", args.ffmpeg_video_transcode)
     if args.ffmpeg_audio_transcode:
         streamlink.set_option("ffmpeg-audio-transcode", args.ffmpeg_audio_transcode)
+    if args.ffmpeg_copyts:
+        streamlink.set_option("ffmpeg-copyts", args.ffmpeg_copyts)
     if args.ffmpeg_start_at_zero:
         streamlink.set_option("ffmpeg-start-at-zero", args.ffmpeg_start_at_zero)
 


Test patch:
diff --git a/tests/streams/test_ffmpegmux.py b/tests/streams/test_ffmpegmux.py
index 4865eaf9..7ab2e698 100644
--- a/tests/streams/test_ffmpegmux.py
+++ b/tests/streams/test_ffmpegmux.py
@@ -65,6 +65,19 @@ def test_ffmpeg_open_copyts(session):
                                      stdin=ANY)
 
 
+def test_ffmpeg_open_copyts_user_override(session):
+    with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
+        session.options.set("ffmpeg-copyts", True)
+        f = FFMPEGMuxer(session, copyts=False)
+        with patch('subprocess.Popen') as popen:
+            f.open()
+            popen.assert_called_with(['ffmpeg', '-nostats', '-y', '-c:v', FFMPEGMuxer.DEFAULT_VIDEO_CODEC, '-c:a',
+                                      FFMPEGMuxer.DEFAULT_AUDIO_CODEC, '-copyts', '-f', 'matroska', 'pipe:1'],
+                                     stderr=ANY,
+                                     stdout=ANY,
+                                     stdin=ANY)
+
+
 def test_ffmpeg_open_copyts_disable_session_start_at_zero(session):
     with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
         session.options.set("ffmpeg-start-at-zero", False)
@@ -78,6 +91,20 @@ def test_ffmpeg_open_copyts_disable_session_start_at_zero(session):
                                      stdin=ANY)
 
 
+def test_ffmpeg_open_copyts_disable_session_start_at_zero_user_override(session):
+    with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
+        session.options.set("ffmpeg-copyts", True)
+        session.options.set("ffmpeg-start-at-zero", False)
+        f = FFMPEGMuxer(session, copyts=False)
+        with patch('subprocess.Popen') as popen:
+            f.open()
+            popen.assert_called_with(['ffmpeg', '-nostats', '-y', '-c:v', FFMPEGMuxer.DEFAULT_VIDEO_CODEC, '-c:a',
+                                      FFMPEGMuxer.DEFAULT_AUDIO_CODEC, '-copyts', '-f', 'matroska', 'pipe:1'],
+                                     stderr=ANY,
+                                     stdout=ANY,
+                                     stdin=ANY)
+
+
 def test_ffmpeg_open_copyts_enable_session_start_at_zero(session):
     with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
         session.options.set("ffmpeg-start-at-zero", True)
@@ -91,6 +118,20 @@ def test_ffmpeg_open_copyts_enable_session_start_at_zero(session):
                                      stdin=ANY)
 
 
+def test_ffmpeg_open_copyts_enable_session_start_at_zero_user_override(session):
+    with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
+        session.options.set("ffmpeg-copyts", True)
+        session.options.set("ffmpeg-start-at-zero", True)
+        f = FFMPEGMuxer(session, copyts=False)
+        with patch('subprocess.Popen') as popen:
+            f.open()
+            popen.assert_called_with(['ffmpeg', '-nostats', '-y', '-c:v', FFMPEGMuxer.DEFAULT_VIDEO_CODEC, '-c:a',
+                                      FFMPEGMuxer.DEFAULT_AUDIO_CODEC, '-copyts', '-start_at_zero', '-f', 'matroska', 'pipe:1'],
+                                     stderr=ANY,
+                                     stdout=ANY,
+                                     stdin=ANY)
+
+
 def test_ffmpeg_open_copyts_disable_start_at_zero(session):
     with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
         f = FFMPEGMuxer(session, copyts=True, start_at_zero=False)
@@ -103,6 +144,19 @@ def test_ffmpeg_open_copyts_disable_start_at_zero(session):
                                      stdin=ANY)
 
 
+def test_ffmpeg_open_copyts_disable_start_at_zero_user_override(session):
+    with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
+        session.options.set("ffmpeg-copyts", True)
+        f = FFMPEGMuxer(session, copyts=False, start_at_zero=False)
+        with patch('subprocess.Popen') as popen:
+            f.open()
+            popen.assert_called_with(['ffmpeg', '-nostats', '-y', '-c:v', FFMPEGMuxer.DEFAULT_VIDEO_CODEC, '-c:a',
+                                      FFMPEGMuxer.DEFAULT_AUDIO_CODEC, '-copyts', '-f', 'matroska', 'pipe:1'],
+                                     stderr=ANY,
+                                     stdout=ANY,
+                                     stdin=ANY)
+
+
 def test_ffmpeg_open_copyts_enable_start_at_zero(session):
     with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
         f = FFMPEGMuxer(session, copyts=True, start_at_zero=True)
@@ -115,6 +169,19 @@ def test_ffmpeg_open_copyts_enable_start_at_zero(session):
                                      stdin=ANY)
 
 
+def test_ffmpeg_open_copyts_enable_start_at_zero_user_override(session):
+    with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
+        session.options.set("ffmpeg-copyts", True)
+        f = FFMPEGMuxer(session, copyts=False, start_at_zero=True)
+        with patch('subprocess.Popen') as popen:
+            f.open()
+            popen.assert_called_with(['ffmpeg', '-nostats', '-y', '-c:v', FFMPEGMuxer.DEFAULT_VIDEO_CODEC, '-c:a',
+                                      FFMPEGMuxer.DEFAULT_AUDIO_CODEC, '-copyts', '-start_at_zero', '-f', 'matroska', 'pipe:1'],
+                                     stderr=ANY,
+                                     stdout=ANY,
+                                     stdin=ANY)
+
+
 def test_ffmpeg_open_vcodec(session):
     with patch('streamlink.stream.ffmpegmux.which', return_value="ffmpeg"):
         f = FFMPEGMuxer(session, vcodec="avc")