Repository: streamlink/streamlink

Issue:
plugins.mildom: Download stopping abruptly and exiting
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest build from the master branch

### Description

Lately Streamlink’s been stopping the download at around 2 hours. The most recent error I got was earlier when both my friend and I were downloading the same stream and it crashed, this is the log:
```
[stream.hls][warning] Failed to reload playlist: Unable to open URL: https://txlvb-play-hls.mildom.tv/live/11868304.m3u8?rid=11868304&uid=0&token=6da7b7e157b81f5f39bdc9a59fb1ec82&sign=1e7eeb3b98a4a194&time=1648562383&did=pc-gp-d3e84717-29d7-4b52-871a-ac9acecb89a5&cnt=1&expire=0 (403 Client Error: Forbidden for url: https://txlvb-play-hls.mildom.tv/live/11868304.m3u8?rid=11868304&uid=0&token=6da7b7e157b81f5f39bdc9a59fb1ec82&sign=1e7eeb3b98a4a194&time=1648562383&did=pc-gp-d3e84717-29d7-4b52-871a-ac9acecb89a5&cnt=1&expire=0)
[stream.hls][warning] Failed to reload playlist: Unable to open URL: https://txlvb-play-hls.mildom.tv/live/11868304.m3u8?rid=11868304&uid=0&token=6da7b7e157b81f5f39bdc9a59fb1ec82&sign=1e7eeb3b98a4a194&time=1648562383&did=pc-gp-d3e84717-29d7-4b52-871a-ac9acecb89a5&cnt=1&expire=0 (403 Client Error: Forbidden for url: https://txlvb-play-hls.mildom.tv/live/11868304.m3u8?rid=11868304&uid=0&token=6da7b7e157b81f5f39bdc9a59fb1ec82&sign=1e7eeb3b98a4a194&time=1648562383&did=pc-gp-d3e84717-29d7-4b52-871a-ac9acecb89a5&cnt=1&expire=0)
[stream.hls][warning] Failed to reload playlist: Unable to open URL: https://txlvb-play-hls.mildom.tv/live/11868304.m3u8?rid=11868304&uid=0&token=6da7b7e157b81f5f39bdc9a59fb1ec82&sign=1e7eeb3b98a4a194&time=1648562383&did=pc-gp-d3e84717-29d7-4b52-871a-ac9acecb89a5&cnt=1&expire=0 (403 Client Error: Forbidden for url: https://txlvb-play-hls.mildom.tv/live/11868304.m3u8?rid=11868304&uid=0&token=6da7b7e157b81f5f39bdc9a59fb1ec82&sign=1e7eeb3b98a4a194&time=1648562383&did=pc-gp-d3e84717-29d7-4b52-871a-ac9acecb89a5&cnt=1&expire=0)
error: Error when reading from stream: Read timeout, exiting
[cli][info] Stream ended
[cli][info] Closing currently open stream...
```

The code that I’ve been using is:
`streamlink --output C:\Users\USER\Videos\selly.ts https://www.mildom.com/11868304 best`

### Debug log

```text
Microsoft Windows [Version 10.0.19042.1526]
(c) Microsoft Corporation. All rights reserved.

C:\Users\USER>streamlink --loglevel debug
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.9.1
[cli][debug] Streamlink: 3.2.0
[cli][debug] Requests(2.27.1), Socks(1.7.1), Websocket(1.2.3)
[cli][debug] Arguments:
[cli][debug]  --loglevel=debug
[cli][debug]  --ffmpeg-ffmpeg=C:\Program Files (x86)\Streamlink\ffmpeg\ffmpeg.exe
usage: streamlink [OPTIONS] <URL> [STREAM]

Use -h/--help to see the available options or read the manual at https://streamlink.github.io

C:\Users\USER>
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/mildom.py b/src/streamlink/plugins/mildom.py
--- a/src/streamlink/plugins/mildom.py
+++ b/src/streamlink/plugins/mildom.py
@@ -6,6 +6,7 @@
 
 import logging
 import re
+from time import time
 from uuid import uuid4
 
 from streamlink.plugin import Plugin, pluginmatcher
@@ -16,21 +17,56 @@
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(r"""
-    https?://(?:www\.)?mildom\.com/
-    (?:
-        playback/(\d+)(/(?P<video_id>(\d+)-(\w+)))
-        |
-        (?P<channel_id>\d+)
-    )
-""", re.VERBOSE))
-class Mildom(Plugin):
-    def _get_vod_streams(self, video_id):
+class MildomHLSStream(HLSStream):
+    __shortname__ = "hls-mildom"
+    expiry_time = 60 * 120
+
+    def __init__(self, session_, api, server, token, quality, **args):
+        self.session = session_
+        self.api = api
+        self.server = server
+        self.token = token
+        self.quality = quality
+        self._url = self.build_hls_url()
+        super().__init__(self.session, self._url, **args)
+        self.expiry = time()
+
+    def build_hls_url(self):
+        if not self.server or not self.token:
+            raise ValueError("server and token must be set")
+        return url_concat(self.server, f"{self.api.channel_id}{self.quality}.m3u8?{self.token}")
+
+    @property
+    def url(self):
+        if time() - self.expiry > MildomHLSStream.expiry_time:
+            self.expiry = time()
+            self.token = self.api.get_token()
+            self._url = self.build_hls_url()
+            log.debug("Updated HLS playlist URL query string")
+        return self._url
+
+
+class MildomAPI:
+    def __init__(self, session, channel_id=None, video_id=None):
+        self.session = session
+        self.channel_id = channel_id
+        self.video_id = video_id
+
+    def _is_api_error(self, data):
+        log.trace(f"{data!r}")
+        if data["code"] != 0:
+            log.debug(data.get("message", "Mildom API returned an error"))
+            return True
+        return False
+
+    def get_vod_streams_data(self):
+        if not self.video_id:
+            return
         data = self.session.http.get(
             "https://cloudac.mildom.com/nonolive/videocontent/playback/getPlaybackDetail",
             params={
                 "__platform": "web",
-                "v_id": video_id,
+                "v_id": self.video_id,
             },
             schema=validate.Schema(validate.parse_json(), {
                 "code": int,
@@ -42,88 +78,75 @@ def _get_vod_streams(self, video_id):
                 },
             })
         )
-        log.trace(f"{data!r}")
-        if data["code"] != 0:
-            log.debug(data.get("message", "Mildom API returned an error"))
+        if self._is_api_error(data):
             return
-        for stream in data["body"]["playback"]["video_link"]:
-            yield stream["name"], HLSStream(self.session, stream["url"])
+        if data.get("body"):
+            return data["body"]["playback"]["video_link"]
 
-    def _get_live_streams(self, channel_id):
-        # Get quality info and check if user is live1
-        data = self.session.http.get(
-            "https://cloudac.mildom.com/nonolive/gappserv/live/enterstudio",
+    def get_token(self):
+        if not self.channel_id:
+            return
+        data = self.session.http.post(
+            "https://cloudac.mildom.com/nonolive/gappserv/live/token",
             params={
                 "__platform": "web",
-                "user_id": channel_id,
+                "__guest_id": "pc-gp-{}".format(uuid4()),
             },
             headers={"Accept-Language": "en"},
+            json={"host_id": self.channel_id, "type": "hls"},
             schema=validate.Schema(
                 validate.parse_json(),
                 {
                     "code": int,
                     validate.optional("message"): str,
                     validate.optional("body"): {
-                        validate.optional("status"): int,
-                        "anchor_live": int,
-                        validate.optional("live_type"): int,
-                        "ext": {
-                            "cmode_params": [{
-                                "cmode": str,
-                                "name": str,
-                            }],
-                            validate.optional("live_mode"): int,
-                        },
-                    },
-                },
+                        "data": [
+                            {"token": str, }
+                        ],
+                    }
+                }
             )
         )
-        log.trace(f"{data!r}")
-        if data["code"] != 0:
-            log.debug(data.get("message", "Mildom API returned an error"))
-            return
-        if data["body"]["anchor_live"] != 11:
-            log.debug("User doesn't appear to be live")
+        if self._is_api_error(data):
             return
-        qualities = []
-        for quality_info in data["body"]["ext"]["cmode_params"]:
-            qualities.append((quality_info["name"], "_" + quality_info["cmode"] if quality_info["cmode"] != "raw" else ""))
+        if data.get("body"):
+            return data["body"]["data"][0]["token"]
 
-        # Get token
-        data = self.session.http.post(
-            "https://cloudac.mildom.com/nonolive/gappserv/live/token",
+    def get_server(self):
+        if not self.channel_id:
+            return
+        data = self.session.http.get(
+            "https://cloudac.mildom.com/nonolive/gappserv/live/liveserver",
             params={
                 "__platform": "web",
-                "__guest_id": "pc-gp-{}".format(uuid4()),
+                "user_id": self.channel_id,
+                "live_server_type": "hls",
             },
             headers={"Accept-Language": "en"},
-            json={"host_id": channel_id, "type": "hls"},
             schema=validate.Schema(
                 validate.parse_json(),
                 {
                     "code": int,
                     validate.optional("message"): str,
                     validate.optional("body"): {
-                        "data": [
-                            {"token": str, }
-                        ],
+                        "stream_server": validate.url(),
                     }
                 }
             )
         )
-        log.trace(f"{data!r}")
-        if data["code"] != 0:
-            log.debug(data.get("message", "Mildom API returned an error"))
+        if self._is_api_error(data):
             return
-        token = data["body"]["data"][0]["token"]
+        if data.get("body"):
+            return data["body"]["stream_server"]
 
-        # Create stream URLs
+    def get_live_streams_data(self):
+        if not self.channel_id:
+            return
         data = self.session.http.get(
-            "https://cloudac.mildom.com/nonolive/gappserv/live/liveserver",
+            "https://cloudac.mildom.com/nonolive/gappserv/live/enterstudio",
             params={
                 "__platform": "web",
-                "user_id": channel_id,
-                "live_server_type": "hls",
+                "user_id": self.channel_id,
             },
             headers={"Accept-Language": "en"},
             schema=validate.Schema(
@@ -132,28 +155,61 @@ def _get_live_streams(self, channel_id):
                     "code": int,
                     validate.optional("message"): str,
                     validate.optional("body"): {
-                        "stream_server": validate.url(),
-                    }
-                }
+                        validate.optional("status"): int,
+                        "anchor_live": int,
+                        validate.optional("live_type"): int,
+                        "ext": {
+                            "cmode_params": [{
+                                "cmode": str,
+                                "name": str,
+                            }],
+                            validate.optional("live_mode"): int,
+                        },
+                    },
+                },
             )
         )
-        log.trace(f"{data!r}")
-        if data["code"] != 0:
-            log.debug(data.get("message", "Mildom API returned an error"))
+        if self._is_api_error(data):
             return
-        base_url = url_concat(data["body"]["stream_server"], f"{channel_id}{{}}.m3u8?{token}")
-        self.session.http.headers.update({"Referer": "https://www.mildom.com/"})
-        for quality in qualities:
-            yield quality[0], HLSStream(self.session, base_url.format(quality[1]))
+        if data.get("body"):
+            return data["body"]
+
 
+@pluginmatcher(re.compile(r"""
+    https?://(?:www\.)?mildom\.com/
+    (?:
+        playback/(\d+)(/(?P<video_id>(\d+)-(\w+)))
+        |
+        (?P<channel_id>\d+)
+    )
+""", re.VERBOSE))
+class Mildom(Plugin):
     def _get_streams(self):
-        channel_id = self.match.group("channel_id")
-        video_id = self.match.group("video_id")
-        if video_id:
-            return self._get_vod_streams(video_id)
+        api = MildomAPI(self.session, channel_id=self.match.group("channel_id"), video_id=self.match.group("video_id"))
+
+        if api.video_id:
+            data = api.get_vod_streams_data()
+            if data:
+                for stream in data:
+                    yield stream["name"], HLSStream(self.session, stream["url"])
         else:
-            return self._get_live_streams(channel_id)
-        return
+            data = api.get_live_streams_data()
+            if not data:
+                return
+
+            if data["anchor_live"] != 11:
+                log.debug("User doesn't appear to be live")
+                return
+
+            qualities = []
+            for quality_info in data["ext"]["cmode_params"]:
+                qualities.append((quality_info["name"], "_" + quality_info["cmode"] if quality_info["cmode"] != "raw" else ""))
+
+            server = api.get_server()
+            token = api.get_token()
+            self.session.http.headers.update({"Referer": "https://www.mildom.com/"})
+            for quality in qualities:
+                yield quality[0], MildomHLSStream(self.session, api, server, token, quality[1])
 
 
 __plugin__ = Mildom


Test patch: