mirror of https://github.com/streamlink/streamlink
utils.times: rewrite hours_minutes_seconds()
- Add support for floats via `hours_minutes_seconds_float()` - Add support for negative timestamps - Rewrite regexes and be as strict as possible - Rewrite and parametrize tests with an extensive list of formats - Update cli.argparser/plugin arguments using `hours_minutes_seconds()`
This commit is contained in:
parent
5beda9470a
commit
1997443624
|
@ -152,12 +152,11 @@ class NicoLiveHLSStream(HLSStream):
|
|||
"timeshift-offset",
|
||||
type=hours_minutes_seconds,
|
||||
argument_name="niconico-timeshift-offset",
|
||||
metavar="[HH:]MM:SS",
|
||||
default=None,
|
||||
metavar="[[XX:]XX:]XX | [XXh][XXm][XXs]",
|
||||
help="""
|
||||
Amount of time to skip from the beginning of a stream.
|
||||
|
||||
Default is 00:00:00.
|
||||
Default is 0.
|
||||
""",
|
||||
)
|
||||
class NicoLive(Plugin):
|
||||
|
|
|
@ -29,7 +29,7 @@ from streamlink.stream.http import HTTPStream
|
|||
from streamlink.utils.args import keyvalue
|
||||
from streamlink.utils.parse import parse_json, parse_qsd
|
||||
from streamlink.utils.random import CHOICES_ALPHA_NUM, random_token
|
||||
from streamlink.utils.times import fromtimestamp, hours_minutes_seconds
|
||||
from streamlink.utils.times import fromtimestamp, hours_minutes_seconds_float
|
||||
from streamlink.utils.url import update_qsd
|
||||
|
||||
|
||||
|
@ -859,7 +859,7 @@ class Twitch(Plugin):
|
|||
time_offset = self.params.get("t", 0)
|
||||
if time_offset:
|
||||
try:
|
||||
time_offset = hours_minutes_seconds(time_offset)
|
||||
time_offset = hours_minutes_seconds_float(time_offset)
|
||||
except ValueError:
|
||||
time_offset = 0
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import re
|
||||
from datetime import datetime, timezone, tzinfo
|
||||
from typing import Callable, Literal, Union, overload
|
||||
|
||||
from isodate import LOCAL, parse_datetime # type: ignore[import]
|
||||
|
||||
|
@ -23,52 +24,107 @@ def fromlocaltimestamp(timestamp: float) -> datetime:
|
|||
return datetime.fromtimestamp(timestamp, tz=LOCAL)
|
||||
|
||||
|
||||
_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)
|
||||
_re_hms_float = re.compile(
|
||||
r"^-?\d+(?:\.\d+)?$",
|
||||
)
|
||||
_re_hms_s = re.compile(
|
||||
r"""
|
||||
^
|
||||
-?
|
||||
(?P<seconds>\d+(?:\.\d+)?)
|
||||
s
|
||||
$
|
||||
""",
|
||||
re.VERBOSE | re.IGNORECASE,
|
||||
)
|
||||
# noinspection RegExpSuspiciousBackref
|
||||
_re_hms_ms = re.compile(
|
||||
r"""
|
||||
^
|
||||
-?
|
||||
(?P<minutes>\d+)
|
||||
(?:(?P<sep>m)|:(?=.))
|
||||
(?:
|
||||
(?P<seconds>[0-5]?[0-9](?:\.\d+)?)
|
||||
(?(sep)s|)
|
||||
)?
|
||||
$
|
||||
""",
|
||||
re.VERBOSE | re.IGNORECASE,
|
||||
)
|
||||
# noinspection RegExpSuspiciousBackref
|
||||
_re_hms_hms = re.compile(
|
||||
r"""
|
||||
^
|
||||
-?
|
||||
(?P<hours>\d+)
|
||||
(?:(?P<sep>h)|:(?=.))
|
||||
(?:
|
||||
(?P<minutes>[0-5]?[0-9])
|
||||
(?(sep)m|:(?=.))
|
||||
)?
|
||||
(?:
|
||||
(?P<seconds>[0-5]?[0-9](?:\.\d+)?)
|
||||
(?(sep)s|)
|
||||
)?
|
||||
$
|
||||
""",
|
||||
re.VERBOSE | re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def hours_minutes_seconds(value):
|
||||
"""converts a timestamp to seconds
|
||||
@overload
|
||||
def _hours_minutes_seconds(as_float: Literal[False]) -> Callable[[str], int]: ... # pragma: no cover
|
||||
|
||||
- hours:minutes:seconds to seconds
|
||||
- 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
|
||||
@overload
|
||||
def _hours_minutes_seconds(as_float: Literal[True]) -> Callable[[str], float]: ... # pragma: no cover
|
||||
|
||||
|
||||
def _hours_minutes_seconds(as_float: bool = True) -> Callable[[str], Union[float, int]]:
|
||||
"""
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
Convert an optionally negative HMS-timestamp string to seconds, as float or int
|
||||
|
||||
match = (_hours_minutes_seconds_re.match(value)
|
||||
or _hours_minutes_seconds_2_re.match(value))
|
||||
if not match:
|
||||
raise ValueError
|
||||
Accepted formats:
|
||||
|
||||
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")
|
||||
- seconds
|
||||
- minutes":"seconds
|
||||
- hours":"minutes":"seconds
|
||||
- seconds"s"
|
||||
- minutes"m"
|
||||
- hours"h"
|
||||
- minutes"m"seconds"s"
|
||||
- hours"h"seconds"s"
|
||||
- hours"h"minutes"m"
|
||||
- hours"h"minutes"m"seconds"s"
|
||||
"""
|
||||
|
||||
return s
|
||||
def inner(value: str) -> Union[int, float]:
|
||||
if _re_hms_float.match(value):
|
||||
return float(value) if as_float else int(float(value))
|
||||
|
||||
match = _re_hms_s.match(value) or _re_hms_ms.match(value) or _re_hms_hms.match(value)
|
||||
if not match:
|
||||
raise ValueError
|
||||
|
||||
data = match.groupdict()
|
||||
|
||||
seconds = 0.0
|
||||
seconds += float(data.get("hours") or 0.0) * 3600.0
|
||||
seconds += float(data.get("minutes") or 0.0) * 60.0
|
||||
seconds += float(data.get("seconds") or 0.0)
|
||||
|
||||
res = -seconds if value[0] == "-" else seconds
|
||||
|
||||
return res if as_float else int(res)
|
||||
|
||||
inner.__name__ = "hours_minutes_seconds"
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
hours_minutes_seconds = _hours_minutes_seconds(as_float=False)
|
||||
hours_minutes_seconds_float = _hours_minutes_seconds(as_float=True)
|
||||
|
||||
|
||||
def seconds_to_hhmmss(seconds):
|
||||
|
@ -90,5 +146,6 @@ __all__ = [
|
|||
"fromtimestamp",
|
||||
"fromlocaltimestamp",
|
||||
"hours_minutes_seconds",
|
||||
"hours_minutes_seconds_float",
|
||||
"seconds_to_hhmmss",
|
||||
]
|
||||
|
|
|
@ -9,7 +9,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|||
from streamlink import __version__ as streamlink_version, logger
|
||||
from streamlink.session import Streamlink
|
||||
from streamlink.utils.args import boolean, comma_list, comma_list_filter, filesize, keyvalue, num
|
||||
from streamlink.utils.times import hours_minutes_seconds
|
||||
from streamlink.utils.times import hours_minutes_seconds_float
|
||||
from streamlink_cli.constants import STREAM_PASSTHROUGH
|
||||
from streamlink_cli.output.player import PlayerOutput
|
||||
from streamlink_cli.utils import find_default_player
|
||||
|
@ -1021,21 +1021,19 @@ def build_parser():
|
|||
)
|
||||
transport_hls.add_argument(
|
||||
"--hls-start-offset",
|
||||
type=hours_minutes_seconds,
|
||||
metavar="[HH:]MM:SS",
|
||||
default=None,
|
||||
type=hours_minutes_seconds_float,
|
||||
metavar="[[XX:]XX:]XX[.XX] | [XXh][XXm][XX[.XX]s]",
|
||||
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 (rewind).
|
||||
|
||||
Default is 00:00:00.
|
||||
Default is 0.
|
||||
""",
|
||||
)
|
||||
transport_hls.add_argument(
|
||||
"--hls-duration",
|
||||
type=hours_minutes_seconds,
|
||||
metavar="[HH:]MM:SS",
|
||||
default=None,
|
||||
type=hours_minutes_seconds_float,
|
||||
metavar="[[XX:]XX:]XX[.XX] | [XXh][XXm][XX[.XX]s]",
|
||||
help="""
|
||||
Limit the playback duration, useful for watching segments of a stream.
|
||||
The actual duration may be slightly longer, as it is rounded to the
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from streamlink.plugins.nicolive import NicoLive
|
||||
from tests.plugins import PluginCanHandleUrl
|
||||
|
||||
|
@ -13,3 +17,18 @@ class TestPluginCanHandleUrlNicoLive(PluginCanHandleUrl):
|
|||
"https://live.nicovideo.jp/watch/co2467009?ref=community",
|
||||
"https://live.nicovideo.jp/watch/co2619719",
|
||||
]
|
||||
|
||||
|
||||
class TestNicoLiveArguments:
|
||||
@pytest.fixture()
|
||||
def parser(self):
|
||||
parser = argparse.ArgumentParser()
|
||||
for parg in NicoLive.arguments or []:
|
||||
parser.add_argument(parg.argument_name("nicolive"), **parg.options)
|
||||
|
||||
return parser
|
||||
|
||||
@pytest.mark.parametrize("timeshift_offset", ["123", "123.45"])
|
||||
def test_timeshift_offset(self, parser: argparse.ArgumentParser, timeshift_offset: str):
|
||||
parsed = parser.parse_args(["--niconico-timeshift-offset", timeshift_offset])
|
||||
assert parsed.niconico_timeshift_offset == 123
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import freezegun
|
||||
|
@ -10,6 +11,7 @@ from streamlink.utils.times import (
|
|||
fromlocaltimestamp,
|
||||
fromtimestamp,
|
||||
hours_minutes_seconds,
|
||||
hours_minutes_seconds_float,
|
||||
localnow,
|
||||
now,
|
||||
parse_datetime,
|
||||
|
@ -54,35 +56,130 @@ class TestDatetime:
|
|||
|
||||
|
||||
class TestHoursMinutesSeconds:
|
||||
def test_hours_minutes_seconds(self):
|
||||
assert hours_minutes_seconds("00:01:30") == 90
|
||||
assert hours_minutes_seconds("01:20:15") == 4815
|
||||
assert hours_minutes_seconds("26:00:00") == 93600
|
||||
|
||||
assert hours_minutes_seconds("07") == 7
|
||||
assert hours_minutes_seconds("444") == 444
|
||||
assert hours_minutes_seconds("8888") == 8888
|
||||
|
||||
assert hours_minutes_seconds("01h") == 3600
|
||||
assert hours_minutes_seconds("01h22m33s") == 4953
|
||||
assert hours_minutes_seconds("01H22M37S") == 4957
|
||||
assert hours_minutes_seconds("01h30s") == 3630
|
||||
assert hours_minutes_seconds("1m33s") == 93
|
||||
assert hours_minutes_seconds("55s") == 55
|
||||
|
||||
assert hours_minutes_seconds("-00:01:40") == 100
|
||||
assert hours_minutes_seconds("-00h02m30s") == 150
|
||||
|
||||
assert hours_minutes_seconds("02:04") == 124
|
||||
assert hours_minutes_seconds("1:10") == 70
|
||||
assert hours_minutes_seconds("10:00") == 600
|
||||
@pytest.mark.parametrize(("sign", "factor"), [
|
||||
pytest.param("", 1, id="positive"),
|
||||
pytest.param("-", -1, id="negative"),
|
||||
])
|
||||
@pytest.mark.parametrize(("timestamp", "as_float", "expected"), [
|
||||
# decimals
|
||||
pytest.param("0", True, 0.0, id="zero"),
|
||||
pytest.param("123", True, 123.0, id="decimal without fraction"),
|
||||
pytest.param("123.456789", True, 123.456789, id="decimal with fraction"),
|
||||
# XX:XX:XX
|
||||
pytest.param("0:0", True, 0.0, id="0:0"),
|
||||
pytest.param("0:0:0", True, 0.0, id="0:0:0"),
|
||||
pytest.param("1:2", True, 62.0, id="X:X"),
|
||||
pytest.param("1:2.3", True, 62.3, id="X:X.X"),
|
||||
pytest.param("12:34", True, 754.0, id="XX:XX"),
|
||||
pytest.param("12:34.56", True, 754.56, id="XX:XX.XX"),
|
||||
pytest.param("123:45", True, 7425.0, id="XXX:XX"),
|
||||
pytest.param("123:45.67", True, 7425.67, id="XXX:XX.XX"),
|
||||
pytest.param("1:2:3", True, 3723.0, id="X:X:X"),
|
||||
pytest.param("1:2:3.4", True, 3723.4, id="X:X:X.X"),
|
||||
pytest.param("12:34:56", True, 45296.0, id="XX:XX:XX"),
|
||||
pytest.param("12:34:56.78", True, 45296.78, id="XX:XX:XX.XX"),
|
||||
pytest.param("123:4:5", True, 443045.0, id="XXX:X:X"),
|
||||
pytest.param("123:4:5.6", True, 443045.6, id="XXX:X:X.X"),
|
||||
# XXhXXmXXs
|
||||
pytest.param("0s", True, 0.0, id="0s"),
|
||||
pytest.param("0m0s", True, 0.0, id="0m0s"),
|
||||
pytest.param("0h0m0s", True, 0.0, id="0h0m0s"),
|
||||
pytest.param("1s", True, 1.0, id="Xs"),
|
||||
pytest.param("1.2s", True, 1.2, id="X.Xs"),
|
||||
pytest.param("12s", True, 12.0, id="XXs"),
|
||||
pytest.param("12.3s", True, 12.3, id="XX.Xs"),
|
||||
pytest.param("123s", True, 123.0, id="XXXs"),
|
||||
pytest.param("123.4s", True, 123.4, id="XXX.Xs"),
|
||||
pytest.param("1m", True, 60.0, id="Xm"),
|
||||
pytest.param("12m", True, 720.0, id="XXm"),
|
||||
pytest.param("123m", True, 7380.0, id="XXXm"),
|
||||
pytest.param("1h", True, 3600.0, id="Xh"),
|
||||
pytest.param("12h", True, 43200.0, id="XXh"),
|
||||
pytest.param("123h", True, 442800.0, id="XXXh"),
|
||||
pytest.param("1m2s", True, 62.0, id="XmXs"),
|
||||
pytest.param("1m2.3s", True, 62.3, id="XmX.Xs"),
|
||||
pytest.param("12m3s", True, 723.0, id="XXmXs"),
|
||||
pytest.param("12m3.4s", True, 723.4, id="XXmX.Xs"),
|
||||
pytest.param("12m34s", True, 754.0, id="XXmXXs"),
|
||||
pytest.param("12m34.5s", True, 754.5, id="XXmXX.Xs"),
|
||||
pytest.param("123m45s", True, 7425.0, id="XXXmXXs"),
|
||||
pytest.param("123m45.6s", True, 7425.6, id="XXXmXX.Xs"),
|
||||
pytest.param("1h2m3s", True, 3723.0, id="XhXmXs"),
|
||||
pytest.param("12h34m56s", True, 45296.0, id="XXhXXmXXs"),
|
||||
pytest.param("12h34m56.78s", True, 45296.78, id="XXhXXmXX.XXs"),
|
||||
pytest.param("123h4m5s", True, 443045.0, id="XXXhXmXs"),
|
||||
pytest.param("123h4m5.6s", True, 443045.6, id="XXXhXmX.Xs"),
|
||||
pytest.param("1h2s", True, 3602.0, id="XhXs"),
|
||||
pytest.param("1h2m", True, 3720.0, id="XhXs"),
|
||||
pytest.param("12.34S", True, 12.34, id="XX.XXS"),
|
||||
pytest.param("12M34.56S", True, 754.56, id="XXMXX.XXS"),
|
||||
pytest.param("12H34M56.78S", True, 45296.78, id="XXHXXMXX.XXS"),
|
||||
# integers
|
||||
pytest.param("0", False, 0, id="zero (int)"),
|
||||
pytest.param("123", False, 123, id="decimal without fraction (int)"),
|
||||
pytest.param("123.456789", False, 123, id="decimal with fraction (int)"),
|
||||
pytest.param("12:34:56", False, 45296, id="XX:XX:XX (int)"),
|
||||
pytest.param("12:34:56.78", False, 45296, id="XX:XX:XX.XX (int)"),
|
||||
pytest.param("12h34m56s", False, 45296, id="XXhXXmXXs (int)"),
|
||||
pytest.param("12h34m56.78s", False, 45296, id="XXhXXmXX.XXs (int)"),
|
||||
# base 10
|
||||
pytest.param("0123", True, 123.0, id="base10"),
|
||||
pytest.param("08:08:08", True, 29288.0, id="XX:XX:XX base10"),
|
||||
pytest.param("08h08m08s", True, 29288.0, id="XXhXXmXXs base10"),
|
||||
])
|
||||
def test_hours_minutes_seconds(self, timestamp: str, as_float: bool, sign: str, factor: int, expected: float):
|
||||
method = hours_minutes_seconds_float if as_float else hours_minutes_seconds
|
||||
res = method(f"{sign}{timestamp}")
|
||||
assert type(res) is type(expected)
|
||||
assert res == factor * expected
|
||||
|
||||
@pytest.mark.parametrize("timestamp", [
|
||||
# missing timestamp
|
||||
pytest.param("", id="empty"),
|
||||
pytest.param(" ", id="whitespace"),
|
||||
# invalid numbers
|
||||
pytest.param("+123", id="plus sign"),
|
||||
pytest.param("1e10", id="exponent notation"),
|
||||
pytest.param("1_000", id="digit notation"),
|
||||
pytest.param("NaN", id="NaN"),
|
||||
pytest.param("infinity", id="infinity"),
|
||||
pytest.param("0xff", id="base16"),
|
||||
# invalid format
|
||||
pytest.param("foo", id="invalid input"),
|
||||
pytest.param(" 1:2:3 ", id="untrimmed input"),
|
||||
pytest.param(":1:2", id="missing hours value"),
|
||||
pytest.param("1::2", id="missing minutes value"),
|
||||
pytest.param("1:2:", id="missing seconds value"),
|
||||
pytest.param("foo:1:2", id="invalid hours"),
|
||||
pytest.param("1:foo:2", id="invalid minutes"),
|
||||
pytest.param("1:2:foo", id="invalid seconds"),
|
||||
pytest.param("1:60", id="seconds with two digits gte 60"),
|
||||
pytest.param("1:60:59", id="minutes with two digits gte 60"),
|
||||
pytest.param("1:234", id="minutes and seconds with three digits"),
|
||||
pytest.param("1:234:56", id="hours and minutes with three digits"),
|
||||
pytest.param("1:23:456", id="hours and seconds with three digits"),
|
||||
pytest.param("1h2", id="missing minutes or seconds suffix"),
|
||||
pytest.param("1m2", id="missing seconds suffix"),
|
||||
pytest.param("1.2h", id="hours fraction"),
|
||||
pytest.param("1.2m", id="minutes fraction"),
|
||||
pytest.param("1:2s", id="mixed format"),
|
||||
pytest.param("1h2:3", id="mixed format"),
|
||||
pytest.param("1:2:3s", id="mixed format"),
|
||||
pytest.param("1:2m3s", id="mixed format"),
|
||||
])
|
||||
def test_hours_minutes_seconds_exception(self, timestamp: str):
|
||||
with pytest.raises(ValueError): # noqa: PT011
|
||||
hours_minutes_seconds("FOO")
|
||||
with pytest.raises(ValueError): # noqa: PT011
|
||||
hours_minutes_seconds("BAR")
|
||||
with pytest.raises(ValueError): # noqa: PT011
|
||||
hours_minutes_seconds("11:ERR:00")
|
||||
hours_minutes_seconds_float(timestamp)
|
||||
|
||||
@pytest.mark.parametrize("method", [hours_minutes_seconds, hours_minutes_seconds_float])
|
||||
def test_hours_minutes_seconds_argparse_failure(self, capfd: pytest.CaptureFixture, method):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("hms", type=method)
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["invalid"])
|
||||
stderr = capfd.readouterr().err
|
||||
assert "error: argument hms: invalid hours_minutes_seconds value: 'invalid'\n" in stderr, \
|
||||
"has the correct method name, so argparse errors are useful"
|
||||
|
||||
def test_seconds_to_hhmmss(self):
|
||||
assert seconds_to_hhmmss(0) == "00:00:00"
|
||||
|
|
Loading…
Reference in New Issue