Repository: streamlink/streamlink
Pull request #1710 (2018-05-30 11:14:48)

hls: support for live streams when using --hls-duration
@pcolmer asked if it was possible to stop a live stream after a given time in #1709. This PR enhances the functionality of the existing `--hls-duration` option to include support for live streams. 

Had to fix a test too, it didn't seem to be working as intended anyway.


Patch:
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
index 92fd7d75..e460d466 100644
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -1,17 +1,17 @@
+import logging
 import re
 import struct
-from collections import defaultdict, namedtuple
 
+from collections import defaultdict, namedtuple
 from Crypto.Cipher import AES
 
+from streamlink.exceptions import StreamError
 from streamlink.stream import hls_playlist
 from streamlink.stream.ffmpegmux import FFMPEGMuxer, MuxedStream
 from streamlink.stream.http import HTTPStream
 from streamlink.stream.segmented import (SegmentedStreamReader,
                                          SegmentedStreamWriter,
                                          SegmentedStreamWorker)
-from ..exceptions import StreamError
-import logging
 
 log = logging.getLogger(__name__)
 Sequence = namedtuple("Sequence", "num segment")
@@ -154,7 +154,7 @@ class HLSStreamWorker(SegmentedStreamWorker):
         self.playlist_reload_retries = self.session.options.get("hls-playlist-reload-attempts")
         self.duration_offset_start = int(self.stream.start_offset + (self.session.options.get("hls-start-offset") or 0))
         self.duration_limit = self.stream.duration or (
-        int(self.session.options.get("hls-duration")) if self.session.options.get("hls-duration") else None)
+            int(self.session.options.get("hls-duration")) if self.session.options.get("hls-duration") else None)
         self.hls_live_restart = self.stream.force_restart or self.session.options.get("hls-live-restart")
 
         self.reload_playlist()
@@ -168,15 +168,13 @@ class HLSStreamWorker(SegmentedStreamWorker):
 
         if self.duration_offset_start != 0:
             self.playlist_sequence = self.duration_to_sequence(self.duration_offset_start, self.playlist_sequences)
-            if self.duration_limit and self.playlist_end:
-                self.playlist_end = self.duration_to_sequence(self.duration_offset_start + self.duration_limit,
-                                                              self.playlist_sequences)
+
         if self.playlist_sequences:
             log.debug("First Sequence: {0}; Last Sequence: {1}",
                       self.playlist_sequences[0].num, self.playlist_sequences[-1].num)
             log.debug("Start offset: {0}; Duration: {1}; Start Sequence: {2}; End Sequence: {3}",
-                      self.duration_offset_start, self.duration_limit, self.playlist_sequence,
-                      self.playlist_end)
+                      self.duration_offset_start, self.duration_limit,
+                      self.playlist_sequence, self.playlist_end)
 
     def reload_playlist(self):
         if self.closed:
@@ -252,10 +250,15 @@ class HLSStreamWorker(SegmentedStreamWorker):
         return default
 
     def iter_segments(self):
+        total_duration = 0
         while not self.closed:
             for sequence in filter(self.valid_sequence, self.playlist_sequences):
                 log.debug("Adding segment {0} to queue", sequence.num)
                 yield sequence
+                total_duration += sequence.segment.duration
+                if self.duration_limit and total_duration >= self.duration_limit:
+                    log.info("Stopping stream early after {0}".format(self.duration_limit))
+                    return
 
                 # End of stream
                 stream_end = self.playlist_end and sequence.num >= self.playlist_end
@@ -277,7 +280,6 @@ class HLSStreamReader(SegmentedStreamReader):
 
     def __init__(self, stream, *args, **kwargs):
         SegmentedStreamReader.__init__(self, stream, *args, **kwargs)
-        log = logging.getLogger("streamlink.stream.hls")
         self.request_params = dict(stream.args)
         self.timeout = stream.session.options.get("hls-timeout")
 
@@ -400,11 +402,9 @@ class HLSStream(HTTPStream):
                     default_audio = [media]
 
                 # select the first audio stream that matches the users explict language selection
-                if (('*' in audio_select or media.language in audio_select or
-                             media.name in audio_select) or
-                        ((not preferred_audio or media.default) and
-                             locale.explicit and
-                             locale.equivalent(language=media.language))):
+                if (('*' in audio_select or media.language in audio_select or media.name in audio_select) or
+                        ((not preferred_audio or media.default) and locale.explicit and locale.equivalent(
+                            language=media.language))):
                     preferred_audio.append(media)
 
             # final fallback on the first audio stream listed
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 6998685e..28ffee1e 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -797,7 +797,7 @@ def build_parser():
         default=None,
         help="""
         Amount of time to skip from the beginning of the stream.
-        For live streams, this is a negative offset from the end of the stream.
+        For live streams, this is a negative offset from the end of the stream (rewind).
 
         Default is 00:00:00.
         """)
@@ -810,8 +810,6 @@ def build_parser():
         Limit the playback duration, useful for watching segments of a stream. The actual duration may be slightly
         longer, as it is rounded to the nearest HLS segment.
 
-        Has no effect on live streams.
-
         Default is unlimited.
         """)
     transport.add_argument(


Test patch:
diff --git a/tests/test_hls.py b/tests/test_hls.py
index d17d1a52..ba049c4f 100644
--- a/tests/test_hls.py
+++ b/tests/test_hls.py
@@ -1,19 +1,19 @@
-import sys
+import logging
 import os
-if sys.version_info[0:2] == (2, 6):
-    import unittest2 as unittest
-else:
-    import unittest
+import unittest
+from binascii import hexlify
+from functools import partial
 
+import pytest
+import requests_mock
 from Crypto.Cipher import AES
-from binascii import hexlify
+from mock import patch, Mock
 
-from streamlink.stream import hls
 from streamlink.session import Streamlink
-from functools import partial
-from mock import patch, Mock
-import requests_mock
-import pytest
+from streamlink.stream import hls
+
+log = logging.getLogger(__name__)
+
 
 def pkcs7_encode(data, keySize):
     val = keySize - (len(data) % keySize)
@@ -79,8 +79,9 @@ audio_only.m3u8
 
         return playlist + playlistEnd
 
-    def start_streamlink(self, masterPlaylist, kwargs={}):
-        print("Executing streamlink")
+    def start_streamlink(self, masterPlaylist, kwargs=None):
+        kwargs = kwargs or {}
+        log.info("Executing streamlink")
         streamlink = Streamlink()
 
         # Set to default value to avoid a test fail if the default change
@@ -91,11 +92,11 @@ audio_only.m3u8
         stream = masterStream["1080p (source)"].open()
         data = b"".join(iter(partial(stream.read, 8192), b""))
         stream.close()
-        print("End of streamlink execution")
+        log.info("End of streamlink execution")
         return data
 
     def test_hls_non_encrypted(self):
-        streams = [os.urandom(1024) for i in range(4)]
+        streams = [os.urandom(1024) for _ in range(4)]
         masterPlaylist = self.getMasterPlaylist()
         firstSequence = self.mediaSequence
         playlist = self.getPlaylist(None, "stream{0}.ts") + "#EXT-X-ENDLIST\n"
@@ -106,10 +107,11 @@ audio_only.m3u8
                 mock.get("http://mocked/path/stream{0}.ts".format(i), content=stream)
 
             # Start streamlink on the generated stream
-            streamlinkResult = self.start_streamlink("http://mocked/path/master.m3u8", {'start_offset': 1, 'duration': 1})
+            streamlinkResult = self.start_streamlink("http://mocked/path/master.m3u8",
+                                                     {'start_offset': 1, 'duration': 1})
 
-        # Check result
-        expectedResult = b''.join(streams[1:3])
+        # Check result, each segment is 1 second, with duration=1 only one segment should be returned
+        expectedResult = b''.join(streams[1:2])
         self.assertEqual(streamlinkResult, expectedResult)
 
     def test_hls_encryted_aes128(self):