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.

Comments:

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 6b15eb5b..eea306bc 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -125,6 +125,7 @@ metube                  metube.id            Yes   Yes
 mitele                  mitele.es            Yes   No    Streams may be geo-restricted to Spain.
 mixer                   mixer.com            Yes   Yes
 mjunoon                 mjunoon.tv           Yes   Yes
+n13tv                   13tv.co.il           Yes   Yes   Streams may be geo-restricted to Israel.
 nbc                     nbc.com              No    Yes   Streams are geo-restricted to USA. Authentication is not supported.
 nbcnews                 nbcnews.com          Yes   No
 nbcsports               nbcsports.com        No    Yes   Streams maybe be geo-restricted to USA. Authentication is not supported.
diff --git a/src/streamlink/plugins/n13tv.py b/src/streamlink/plugins/n13tv.py
new file mode 100644
index 00000000..0e2662ee
--- /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
index 00000000..dc80f3f7
--- /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))