Repository: streamlink/streamlink

Issue:
Plugin 'reshet' changed URL
<!--
Thanks for reporting a plugin issue!
USE THE TEMPLATE. Otherwise your plugin issue may be rejected.

First, see the contribution guidelines:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

Also check the list of open and closed plugin issues:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22

Please see the text preview to avoid unnecessary formatting errors.
-->


## Plugin Issue

<!-- Replace [ ] with [x] in order to check the box -->
- [x] This is a plugin issue and I have read the contribution guidelines.


### Description

<!-- Explain the plugin issue as thoroughly as you can. -->
Hi,
"reshet.tv" has changed to "13tv.co.il", making the plugin unuseable.
I suspect there are also changes to the API.


Discussion:
It would appear that there is a static non geo-IP restricted URL:

```http://reshet-live-il.ctedgecdn.net/13tv-desktop/r13.m3u8```

```
$ streamlink hls://https://reshet-live-il.ctedgecdn.net/13tv-desktop/r13.m3u8 360p
[cli][info] Found matching plugin hls for URL hls://https://reshet-live-il.ctedgecdn.net/13tv-desktop/r13.m3u8
[cli][info] Available streams: 360p (worst), 480p_alt, 480p (best)
[cli][info] Opening stream: 360p (hls)
[cli][info] Starting player: mpv
[cli][info] Player closed
[cli][info] Stream ended
[cli][info] Closing currently open stream...
```

Hi, @yegarti 

Could you try my replacement plugin at: https://github.com/mkbloke/streamlink/blob/n13tv-plugin/src/streamlink/plugins/n13tv.py

Select 'Raw', then you can save it and copy into your plugins dir to test.  If you can test live and VODs and confirm that all appears to be working OK, that would be great, thanks.

Hi, @mkbloke 
Tested the plugin on some videos, worked great for VODs and live.
It failed for short clips, the stream start, I see the first frames and then the stream immediately ends.
For example:
```
> streamlink -l debug https://13tv.co.il/item/mood/survivor/season-04-vip/clips/enbrm-2027273/ best
cli][debug] OS:         Linux-5.4.40-1-MANJARO-x86_64-with-glibc2.2.5
[cli][debug] Python:     3.8.3
[cli][debug] Streamlink: 1.4.1+16.g90e634e.dirty
[cli][debug] Requests(2.23.0), Socks(1.7.1), Websocket(0.57.0)
[cli][info] Found matching plugin n13tv for URL https://13tv.co.il/item/mood/survivor/season-04-vip/clips/enbrm-2027273/
[plugin.n13tv][debug] Main JS path = /wp-content/themes/reshet_tv/build/static/js/main.9f10f8c1.js
[plugin.n13tv][debug] User ID = 45E4A9FB-FCE8-88BF-93CC-3650C39DDF28
[plugin.n13tv][debug] Video name = mood-survivor-season-04-vip-clips-11-part-10-next
[plugin.n13tv][debug] Media file = /media/iiscdn/2020/06/02/mood-survivor-season-04-vip-clips-11-part-10-next_,550,850,1400,.mp4
[plugin.n13tv][debug] VOD URL = https://reshet-vod-il.ctedgecdn.net/reshet-vod/_definst_/amlst:mediaroot/nana10/media/iiscdn/2020/06/02/mood-survivor-season-04-vip-clips-11-part-10-next_,550,850,1400,.mp4/playlist.m3u8?ctoken=c13079014b2fde1b073f1f22e74c808d459f87e99c0b1983beb7aafbca355baa&str=1591344586&exp=1591345786
[utils.l10n][debug] Language code: en_IL
[cli][info] Available streams: 550k (worst), 850k, 1400k (best)
[cli][info] Opening stream: 1400k (hls)
[stream.hls][debug] Reloading playlist
[stream.hls][debug] First Sequence: 0; Last Sequence: 5
[stream.hls][debug] Start offset: 0; Duration: None; Start Sequence: 0; End Sequence: 5
[stream.hls][debug] Adding segment 0 to queue
[cli][debug] Pre-buffering 8192 bytes
[stream.hls][debug] Adding segment 1 to queue
[stream.hls][debug] Adding segment 2 to queue
[stream.hls][debug] Adding segment 3 to queue
[stream.hls][debug] Adding segment 4 to queue
[stream.hls][debug] Adding segment 5 to queue
[stream.segmented][debug] Closing worker thread
[stream.hls][debug] Download of segment 0 complete
[cli][info] Starting player: /usr/bin/vlc
[cli.output][debug] Opening subprocess: /usr/bin/vlc --input-title-format https://13tv.co.il/item/mood/survivor/season-04-vip/clips/enbrm-2027273/ -
[stream.hls][debug] Download of segment 1 complete
[stream.hls][debug] Download of segment 2 complete
[stream.hls][debug] Download of segment 3 complete
[cli][debug] Writing stream to output
[stream.hls][debug] Download of segment 4 complete
[stream.hls][debug] Download of segment 5 complete
[stream.segmented][debug] Closing writer thread
[cli][info] Stream ended
[cli][info] Closing currently open stream...

```

Beside this small issue (unimportant one?) it's working great, Thank you.
Hi @yegarti,

Many thanks for testing the plugin and for providing the debug output with the clip.  I'm seeing the same thing as you.  On the website there are segments 0 to 5 also.  I seem to be finding that if I if I use the '-O' option and redirect stdout to a file that I get the whole 54 second clip.  It seems to me that perhaps the player is closing before the clip is over.  If you save to file can you get the whole clip?

Thanks again.
Hi @mkbloke,

Using '-O' does save the whole clip.

Thanks again for your response.  I also seem to find that using the '-O' option and then piping into the player on the command line, e.g. '| vlc -' seems to result in the entire clip being played.

I can only conclude that this is an edge-case bug in streamlink, unless I'm missing something else here.  Cheers.
@yegarti @mkbloke 

> I can only conclude that this is an edge-case bug in streamlink, unless I'm missing something else here. 

not a bug, you can use `--player-no-close`
https://streamlink.github.io/cli.html#cmdoption-player-no-close
Ah!  Many thanks, @back-to, for the info on that.  Would it be worthwhile putting a log.info() into the plugin as a note to users?
> Would it be worthwhile putting a log.info() into the plugin

No.

Streamlink will send a SIGTERM signal to the player process as soon as the end of the stream has been reached. Depending on the player, it won't wait with the process termination until it has finished decoding and rendering the remaining content. Either do what @back-to said and set the `--player-no-close` Streamlink parameter, or tell your player to ignore SIGTERM (and SIGKILL) signals. In case of VLC, it's the `--no-play-and-exit` parameter and in case of MPV, it's the `--keep-open=<yes|always>` parameter.
I think maybe there are just crossed wires here.  I was thinking about a ```log.info()``` message recommending usage of ```--player-no-close``` be used on the command line if necessary by the user, but I think what you're suggesting is to use ```self.set_option()``` within the plugin?  If that's the case, I had not realised it from back-to's reply.
Messages on how to use Streamlink don't belong into the log output. I'm also not suggesting to set any kind of output options via plugins, as it's up to the user to decide. I was merely explaining what you can do as a user to make the player stay open after Streamlink as finished reading and writing the stream data.
OK, understood now.  Thank you.

Resolving patch:
diff --git a/src/streamlink/plugins/n13tv.py b/src/streamlink/plugins/n13tv.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/plugins/n13tv.py
@@ -0,0 +1,147 @@
+import logging
+import re
+
+from streamlink.compat import urljoin, urlunparse
+from streamlink.exceptions import PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+
+log = logging.getLogger(__name__)
+
+
+class N13TV(Plugin):
+    url_re = re.compile(r"https?://(?:www\.)?13tv\.co\.il/(live|.*?/)")
+    api_url = "https://13tv-api.oplayer.io/api/getlink/"
+    main_js_url_re = re.compile(r'type="text/javascript" src="(.*?main\..+\.js)"')
+    user_id_re = re.compile(r'"data-ccid":"(.*?)"')
+    video_name_re = re.compile(r'"videoRef":"(.*?)"')
+    server_addr_re = re.compile(r'(.*[^/])(/.*)')
+    media_file_re = re.compile(r'(.*)(\.[^\.].*)')
+
+    live_schema = validate.Schema(validate.all(
+        [{'Link': validate.url()}],
+        validate.get(0),
+        validate.get('Link')
+    ))
+
+    vod_schema = validate.Schema(validate.all([{
+        'ShowTitle': validate.text,
+        'ProtocolType': validate.all(
+            validate.text,
+            validate.transform(lambda x: x.replace("://", ""))
+        ),
+        'ServerAddress': validate.text,
+        'MediaRoot': validate.text,
+        'MediaFile': validate.text,
+        'Bitrates': validate.text,
+        'StreamingType': validate.text,
+        'Token': validate.all(
+            validate.text,
+            validate.transform(lambda x: x.lstrip("?"))
+        )
+    }], validate.get(0)))
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_live(self, user_id):
+        res = self.session.http.get(
+            self.api_url,
+            params=dict(
+                userId=user_id,
+                serverType="web",
+                ch=1,
+                cdnName="casttime"
+            )
+        )
+
+        url = self.session.http.json(res, schema=self.live_schema)
+        log.debug("URL={0}".format(url))
+
+        return HLSStream.parse_variant_playlist(self.session, url)
+
+    def _get_vod(self, user_id, video_name):
+        res = self.session.http.get(
+            urljoin(self.api_url, "getVideoByFileName"),
+            params=dict(
+                userId=user_id,
+                videoName=video_name,
+                serverType="web",
+                callback="x"
+            )
+        )
+
+        vod_data = self.session.http.json(res, schema=self.vod_schema)
+
+        if video_name == vod_data['ShowTitle']:
+            host, base_path = self.server_addr_re.search(
+                vod_data['ServerAddress']
+            ).groups()
+            if not host or not base_path:
+                raise PluginError("Could not split 'ServerAddress' components")
+
+            base_file, file_ext = self.media_file_re.search(
+                vod_data['MediaFile']
+            ).groups()
+            if not base_file or not file_ext:
+                raise PluginError("Could not split 'MediaFile' components")
+
+            media_path = "{0}{1}{2}{3}{4}{5}".format(
+                base_path,
+                vod_data['MediaRoot'],
+                base_file,
+                vod_data['Bitrates'],
+                file_ext,
+                vod_data['StreamingType']
+            )
+            log.debug("Media path={0}".format(media_path))
+
+            vod_url = urlunparse((
+                vod_data['ProtocolType'],
+                host,
+                media_path,
+                '',
+                vod_data['Token'],
+                ''
+            ))
+            log.debug("URL={0}".format(vod_url))
+
+            return HLSStream.parse_variant_playlist(self.session, vod_url)
+
+    def _get_streams(self):
+        m = self.url_re.match(self.url)
+        url_type = m and m.group(1)
+        log.debug("URL type={0}".format(url_type))
+
+        res = self.session.http.get(self.url)
+
+        if url_type != "live":
+            m = self.video_name_re.search(res.text)
+            video_name = m and m.group(1)
+            if not video_name:
+                raise PluginError('Could not determine video_name')
+            log.debug("Video name={0}".format(video_name))
+
+        m = self.main_js_url_re.search(res.text)
+        main_js_path = m and m.group(1)
+        if not main_js_path:
+            raise PluginError('Could not determine main_js_path')
+        log.debug("Main JS path={0}".format(main_js_path))
+
+        res = self.session.http.get(urljoin(self.url, main_js_path))
+
+        m = self.user_id_re.search(res.text)
+        user_id = m and m.group(1)
+        if not user_id:
+            raise PluginError('Could not determine user_id')
+        log.debug("User ID={0}".format(user_id))
+
+        if url_type == "live":
+            return self._get_live(user_id)
+        else:
+            return self._get_vod(user_id, video_name)
+
+
+__plugin__ = N13TV


Test patch:
diff --git a/tests/plugins/test_n13tv.py b/tests/plugins/test_n13tv.py
new file mode 100644
--- /dev/null
+++ b/tests/plugins/test_n13tv.py
@@ -0,0 +1,30 @@
+import unittest
+
+from streamlink.plugins.n13tv import N13TV
+
+
+class TestPluginN13TV(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            "http://13tv.co.il/live",
+            "https://13tv.co.il/live",
+            "http://www.13tv.co.il/live/",
+            "https://www.13tv.co.il/live/",
+            "http://13tv.co.il/item/entertainment/ambush/season-02/episodes/ffuk3-2026112/",
+            "https://13tv.co.il/item/entertainment/ambush/season-02/episodes/ffuk3-2026112/",
+            "http://www.13tv.co.il/item/entertainment/ambush/season-02/episodes/ffuk3-2026112/",
+            "https://www.13tv.co.il/item/entertainment/ambush/season-02/episodes/ffuk3-2026112/"
+            "http://13tv.co.il/item/entertainment/tzhok-mehamatzav/season-01/episodes/vkdoc-2023442/",
+            "https://13tv.co.il/item/entertainment/tzhok-mehamatzav/season-01/episodes/vkdoc-2023442/",
+            "http://www.13tv.co.il/item/entertainment/tzhok-mehamatzav/season-01/episodes/vkdoc-2023442/"
+            "https://www.13tv.co.il/item/entertainment/tzhok-mehamatzav/season-01/episodes/vkdoc-2023442/"
+        ]
+        for url in should_match:
+            self.assertTrue(N13TV.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            "https://www.youtube.com",
+        ]
+        for url in should_not_match:
+            self.assertFalse(N13TV.can_handle_url(url))