Repository: streamlink/streamlink
Pull request #1804 (2018-06-15 15:45:36)

api.utils: hours_minutes_seconds update, twitch automate time offset
**hours_minutes_seconds**:

allow more timestamps
- hours:minutes:seconds to seconds
- 11h22m33s to seconds
- 11h to seconds
- 20h15m to seconds
- seconds to seconds

**twitch**:
- automate `hls-start-offset` for urls with timestamps
- update for the old vod url's, which I don't think exist anymore

**youtube**:
- tested on youtube, but did not add it because there are to many http streams

---

I put it into `api/utils.py` but could also move it somewhere else.

---

will break https://github.com/streamlink/streamlink/pull/1792 as it uses the same file


beardypig: Looks good to me. I restarted the Travis job, it failed due to network errors on their side. 
codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/1804?src=pr&el=h1) Report
> Merging [#1804](https://codecov.io/gh/streamlink/streamlink/pull/1804?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/5a36e512e5adb5b4f8056eda9e4290ffebf8a299?src=pr&el=desc) will **increase** coverage by `0.06%`.
> The diff coverage is `62.06%`.


```diff
@@            Coverage Diff             @@
##           master    #1804      +/-   ##
==========================================
+ Coverage      50%   50.06%   +0.06%     
==========================================
  Files         233      233              
  Lines       13817    13824       +7     
==========================================
+ Hits         6909     6921      +12     
+ Misses       6908     6903       -5
```

beardypig: Maybe this is better in `streamlink.utils`? 
back-to: should it be in a newfile
such as `src/streamlink/utils/times.py`
or
in `src/streamlink/utils/__init__.py` ?
beardypig: I was debating that with myself too... I'm not sure, maybe nicer to have a separate file :) Which do you prefer? 
back-to: I would go with a **separate file** `times.py`,
the `__init__` file is already too packed.
beardypig: Agreed. 

Patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index d42cba22..f1efb7e8 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -13,6 +13,7 @@ from streamlink.plugin.api.utils import parse_json, parse_query
 from streamlink.stream import (
     HTTPStream, HLSStream, FLVPlaylist, extract_flv_header_tags
 )
+from streamlink.utils.times import hours_minutes_seconds
 
 try:
     from itertools import izip as zip
@@ -55,17 +56,6 @@ _url_re = re.compile(r"""
         (?P<clip_name>[\w]+)
     )?
 """, re.VERBOSE)
-_time_re = re.compile(r"""
-    (?:
-        (?P<hours>\d+)h
-    )?
-    (?:
-        (?P<minutes>\d+)m
-    )?
-    (?:
-        (?P<seconds>\d+)s
-    )?
-""", re.VERBOSE)
 
 _access_token_schema = validate.Schema(
     {
@@ -138,18 +128,6 @@ _quality_options_schema = validate.Schema(
 )
 
 
-def time_to_offset(t):
-    match = _time_re.match(t)
-    if match:
-        offset = int(match.group("hours") or "0") * 60 * 60
-        offset += int(match.group("minutes") or "0") * 60
-        offset += int(match.group("seconds") or "0")
-    else:
-        offset = 0
-
-    return offset
-
-
 class UsherService(object):
     def _create_url(self, endpoint, **extra_params):
         url = "https://usher.ttvnw.net{0}".format(endpoint)
@@ -541,7 +519,12 @@ class Twitch(Plugin):
         # start offset if needed.
         time_offset = self.params.get("t")
         if time_offset:
-            videos["start_offset"] += time_to_offset(self.params.get("t"))
+            try:
+                time_offset = hours_minutes_seconds(time_offset)
+            except ValueError:
+                time_offset = 0
+
+            videos["start_offset"] += time_offset
 
         return self._create_playlist_streams(videos)
 
@@ -599,10 +582,18 @@ class Twitch(Plugin):
             self.logger.debug("Unknown HLS stream type: {0}".format(stream_type))
             return {}
 
+        time_offset = self.params.get("t", 0)
+        if time_offset:
+            try:
+                time_offset = hours_minutes_seconds(time_offset)
+            except ValueError:
+                time_offset = 0
+
         try:
             # If the stream is a VOD that is still being recorded the stream should start at the
             # beginning of the recording
             streams = HLSStream.parse_variant_playlist(self.session, url,
+                                                       start_offset=time_offset,
                                                        force_restart=not stream_type == "live")
         except IOError as err:
             err = str(err)
diff --git a/src/streamlink/utils/times.py b/src/streamlink/utils/times.py
new file mode 100644
index 00000000..c31a3158
--- /dev/null
+++ b/src/streamlink/utils/times.py
@@ -0,0 +1,52 @@
+import re
+
+_hours_minutes_seconds_re = re.compile(r"""
+    ^-?(?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d+)$
+""", re.VERBOSE)
+
+_hours_minutes_seconds_2_re = re.compile(r"""^-?
+    (?:
+        (?P<hours>\d+)h
+    )?
+    (?:
+        (?P<minutes>\d+)m
+    )?
+    (?:
+        (?P<seconds>\d+)s
+    )?$
+""", re.VERBOSE | re.IGNORECASE)
+
+
+def hours_minutes_seconds(value):
+    """converts a timestamp to seconds
+
+      - hours:minutes:seconds to seconds
+      - 11h22m33s to seconds
+      - 11h to seconds
+      - 20h15m to seconds
+      - seconds to seconds
+
+    :param value: hh:mm:ss ; 00h00m00s ; seconds
+    :return: seconds
+    """
+    try:
+        return int(value)
+    except ValueError:
+        pass
+
+    match = (_hours_minutes_seconds_re.match(value)
+             or _hours_minutes_seconds_2_re.match(value))
+    if not match:
+        raise ValueError
+
+    s = 0
+    s += int(match.group("hours") or "0") * 60 * 60
+    s += int(match.group("minutes") or "0") * 60
+    s += int(match.group("seconds") or "0")
+
+    return s
+
+
+__all__ = [
+    "hours_minutes_seconds",
+]
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 5562b30d..921bdd37 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -4,6 +4,7 @@ from string import printable
 from textwrap import dedent
 
 from streamlink import logger
+from streamlink.utils.times import hours_minutes_seconds
 from .constants import (
     LIVESTREAMER_VERSION, STREAM_PASSTHROUGH, DEFAULT_PLAYER_ARGUMENTS
 )
@@ -24,7 +25,6 @@ _option_re = re.compile(r"""
     \s*
     (?P<value>.*) # The value, anything goes.
 """, re.VERBOSE)
-_hours_minutes_seconds_re = re.compile(r"-?(?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d+)")
 
 
 class ArgumentParser(argparse.ArgumentParser):
@@ -140,23 +140,6 @@ def boolean(value):
     return value.lower() in truths
 
 
-def hours_minutes_seconds(value):
-    """
-    converts hours:minutes:seconds to seconds
-    :param value: hh:mm:ss
-    :return: seconds
-    """
-    match = _hours_minutes_seconds_re.match(value)
-    if not match:
-        raise ValueError
-    s = 0
-    s += int(match.group("hours")) * 60 * 60
-    s += int(match.group("minutes")) * 60
-    s += int(match.group("seconds"))
-
-    return s
-
-
 def build_parser():
     parser = ArgumentParser(
         prog="streamlink",


Test patch:
diff --git a/tests/test_utils_times.py b/tests/test_utils_times.py
new file mode 100644
index 00000000..7791474b
--- /dev/null
+++ b/tests/test_utils_times.py
@@ -0,0 +1,37 @@
+import unittest
+
+from streamlink.utils.times import hours_minutes_seconds
+
+
+class TestUtilsTimes(unittest.TestCase):
+
+    def test_hours_minutes_seconds(self):
+        self.assertEqual(hours_minutes_seconds("00:01:30"), 90)
+        self.assertEqual(hours_minutes_seconds("01:20:15"), 4815)
+        self.assertEqual(hours_minutes_seconds("26:00:00"), 93600)
+
+        self.assertEqual(hours_minutes_seconds("07"), 7)
+        self.assertEqual(hours_minutes_seconds("444"), 444)
+        self.assertEqual(hours_minutes_seconds("8888"), 8888)
+
+        self.assertEqual(hours_minutes_seconds("01h"), 3600)
+        self.assertEqual(hours_minutes_seconds("01h22m33s"), 4953)
+        self.assertEqual(hours_minutes_seconds("01H22M37S"), 4957)
+        self.assertEqual(hours_minutes_seconds("01h30s"), 3630)
+        self.assertEqual(hours_minutes_seconds("1m33s"), 93)
+        self.assertEqual(hours_minutes_seconds("55s"), 55)
+
+        self.assertEqual(hours_minutes_seconds("-00:01:40"), 100)
+        self.assertEqual(hours_minutes_seconds("-00h02m30s"), 150)
+
+        with self.assertRaises(ValueError):
+            hours_minutes_seconds("02:04")
+
+        with self.assertRaises(ValueError):
+            hours_minutes_seconds("FOO")
+
+        with self.assertRaises(ValueError):
+            hours_minutes_seconds("BAR")
+
+        with self.assertRaises(ValueError):
+            hours_minutes_seconds("11:ERR:00")