Repository: streamlink/streamlink
Pull request #1406 (2018-01-03 14:12:04)

[neulion] Updated the ufctv plugin to make it useable for other domains
#### [neulion] renamed ufctv.py to neulion.py 

---

#### [neulion] Updated the ufctv plugin to make it useable for other domains

- Fixed KeyError if there is no 'type' in the result data

- **--ufctv-username** and **--ufctv-password** can still be used,
  but the new command is **--neulion-username** **--neulion-password**

it should work for
```
https://fanpass.co.nz - Tested
https://watch.nba.com - only free Video Tested
https://watch.rugbypass.com - Not Tested
https://www.elevensports.be - only free Video Tested
https://www.elevensports.lu - only free Video Tested
https://www.elevensports.pl - only free Video Tested
https://www.elevensports.sg - Tested
https://www.elevensports.tw - Tested
https://www.tennischanneleverywhere.com - Tested
https://www.ufc.tv - old plugin nothing changed
```

Fixed streamlink/streamlink#1037
Fixed streamlink/streamlink#1130
Fixed streamlink/streamlink#1185

- Added regex tests

---

#### [neulion] Replace `'` with `"` in js_to_json, show Login required for 400 error in _get_stream_url

---

_**Known issue:**_
- the **ufctv.py** file might not be removed for some windows users streamlink/streamlink#1223


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 796c18e6..6df23acc 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -132,6 +132,17 @@ mixer               mixer.com            Yes   Yes
 mlgtv               mlg.tv               Yes   --
 nbc                 nbc.com              No    Yes   Streams are geo-restricted to USA. Authentication is not supported.
 nbcsports           nbcsports.com        No    Yes   Streams maybe be geo-restricted to USA. Authentication is not supported.
+neulion             - fanpass.co.nz      Yes   Yes   Authentication required for premium streams. Streams may be geo-restricted.
+                    - nba.com
+                    - rugbypass.com
+                    - elevensports.be
+                    - elevensports.lu
+                    - elevensports.pl
+                    - elevensports.sg
+                    - elevensports.tw
+                    - elevensports.tw
+                    - tennischa... [11]_
+                    - ufc.tv
 nhkworld            nhk.or.jp/nhkworld   Yes   No
 nineanime           9anime.to            --    Yes
 nos                 nos.nl               Yes   Yes   Streams may be geo-restricted to Netherlands.
@@ -235,7 +246,6 @@ tvrby               tvr.by               Yes   No    Streams may be geo-restrict
 tvrplus             tvrplus.ro           Yes   No    Streams may be geo-restricted to Romania.
 twitch              twitch.tv            Yes   Yes   Possible to authenticate for access to
                                                      subscription streams.
-ufctv               ufc.tv               Yes   Yes   Authentication required for premium streams.
 ustreamtv           ustream.tv           Yes   Yes
 vaughnlive          - vaughnlive.tv      Yes   --
                     - breakers.tv
@@ -281,3 +291,4 @@ zhanqitv            zhanqi.tv            Yes   No
 .. [8] france3-regions.francetvinfo.fr
 .. [9] tv5mondeplusafrique.com
 .. [10] nettv.netcologne.de
+.. [11] tennischanneleverywhere.com
diff --git a/src/streamlink/plugins/neulion.py b/src/streamlink/plugins/neulion.py
new file mode 100644
index 00000000..995d2644
--- /dev/null
+++ b/src/streamlink/plugins/neulion.py
@@ -0,0 +1,177 @@
+import re
+
+from streamlink.plugin import Plugin, PluginOptions
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.utils import parse_json
+
+
+def js_to_json(data):
+    js_re = re.compile(r'(?!<")(\w+):(?!/)')
+    trimmed = [y.replace("\r", "").strip() for y in data.split(",")]
+    jsons = ','.join([js_re.sub(r'"\1":', x, count=1) for x in trimmed])
+    return parse_json(jsons)
+
+
+def js_to_json_regex_fallback(js_data):
+    """Regex fallback if js_to_json fails"""
+    data_re = re.compile(r"""(?<!\w)(?P<name>id|name|type)["']?:\s?["']?(?P<data>[^"']+)["']?(?:,|(?:\s+)?})""")
+    data_all = data_re.findall(js_data)
+    data_new = {}
+    for name, data in data_all:
+        data_new[name] = data
+    return data_new
+
+
+class Neulion(Plugin):
+    """Streamlink Plugin for websites based on Neulion
+       Example urls can be found in tests/test_plugin_neulion.py
+    """
+
+    url_re = re.compile(r"""https?://
+        (?P<domain>
+            www\.(?:
+                ufc\.tv
+                |
+                elevensports\.(?:be|lu|pl|sg|tw)
+                |
+                tennischanneleverywhere\.com
+                )
+            |
+            watch\.(?:
+                nba\.com
+                |
+                rugbypass\.com
+                )
+            |
+            fanpass\.co\.nz
+        )
+        /(?P<vtype>channel|game|video)/.+""", re.VERBOSE)
+    video_info_re = re.compile(r"""program\s*=\s*(\{.*?});""", re.DOTALL)
+    channel_info_re = re.compile(r"""g_channel\s*=\s(\{.*?});""", re.DOTALL)
+    current_video_re = re.compile(r"""(?:currentVideo|video)\s*=\s*(\{[^;]+});""", re.DOTALL)
+    info_fallback_re = re.compile(r"""
+        var\s?
+        (?:
+            currentGameId
+        |
+            programId
+        )
+        \s?=\s?["']?(?P<id>\d+)["']?;
+        """, re.VERBOSE)
+
+    stream_api_url = "https://{0}/service/publishpoint"
+    auth_url = "https://{0}/secure/authenticate"
+    auth_schema = validate.Schema(validate.xml_findtext("code"))
+
+    options = PluginOptions({
+        "username": None,
+        "password": None
+    })
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    @property
+    def _domain(self):
+        match = self.url_re.match(self.url)
+        return match.group("domain")
+
+    @property
+    def _vtype(self):
+        match = self.url_re.match(self.url)
+        return match.group("vtype")
+
+    def _get_stream_url(self, video_id, vtype):
+        try:
+            res = http.post(self.stream_api_url.format(self._domain), data={
+                "id": video_id,
+                "type": vtype,
+                "format": "json"
+            }, headers={
+                "User-Agent": useragents.IPHONE_6
+            })
+        except Exception as e:
+            if "400 Client Error" in str(e):
+                self.logger.error("Login required")
+                return
+            else:
+                raise e
+
+        data = http.json(res)
+        return data.get("path")
+
+    def _get_info(self, text):
+        # try to find video info first
+        m = self.video_info_re.search(text)
+        if not m:
+            m = self.current_video_re.search(text)
+        if not m:
+            # and channel info if that fails
+            m = self.channel_info_re.search(text)
+        if m:
+            js_data = m.group(1)
+            try:
+                return_data = js_to_json(js_data)
+                self.logger.debug("js_to_json")
+            except Exception as e:
+                self.logger.debug("js_to_json_regex_fallback")
+                return_data = js_to_json_regex_fallback(js_data)
+            finally:
+                return return_data
+
+    def _get_info_fallback(self, text):
+        info_id = self.info_fallback_re.search(text)
+        if info_id:
+            self.logger.debug("Found id from _get_info_fallback")
+            return {"id": info_id.group("id")}
+
+    def _login(self, username, password):
+        res = http.post(self.auth_url.format(self._domain), data={
+            "username": username,
+            "password": password,
+            "cookielink": False
+        })
+        login_status = http.xml(res, schema=self.auth_schema)
+        self.logger.debug("Login status for {0}: {1}", username, login_status)
+        if login_status == "loginlocked":
+            self.logger.error("The account {0} has been locked, the password needs to be reset")
+        return login_status == "loginsuccess"
+
+    def _get_streams(self):
+        login_username = self.get_option("username")
+        login_password = self.get_option("password")
+        if login_username and login_password:
+            self.logger.debug("Attempting login as {0}", login_username)
+            if self._login(login_username, login_password):
+                self.logger.info("Successfully logged in as {0}", login_username)
+            else:
+                self.logger.info("Failed to login as {0}", login_username)
+
+        res = http.get(self.url)
+        video = self._get_info(res.text)
+        if not video:
+            video = self._get_info_fallback(res.text)
+
+        if video:
+            self.logger.debug("Found {type}: {name}".format(
+                type=video.get("type", self._vtype),
+                name=video.get("name", "???")
+            ))
+            surl = self._get_stream_url(video["id"], video.get("type", self._vtype))
+            if surl:
+                surl = surl.replace("_iphone", "")
+                return HLSStream.parse_variant_playlist(self.session, surl)
+            else:
+                self.logger.error("Could not get stream URL for video: {name} ({id})".format(
+                    id=video.get("id", "???"),
+                    name=video.get("name", "???"),
+                ))
+        else:
+            self.logger.error("Could not find any video info on the page")
+
+
+__plugin__ = Neulion
diff --git a/src/streamlink/plugins/ufctv.py b/src/streamlink/plugins/ufctv.py
deleted file mode 100644
index a2959476..00000000
--- a/src/streamlink/plugins/ufctv.py
+++ /dev/null
@@ -1,92 +0,0 @@
-from __future__ import print_function
-
-import re
-import string
-from functools import partial
-
-from streamlink.plugin import Plugin, PluginOptions
-from streamlink.plugin.api import http
-from streamlink.plugin.api import useragents
-from streamlink.plugin.api import validate
-from streamlink.stream import HLSStream
-from streamlink.utils import parse_json
-
-
-def js_to_json(data):
-    js_re = re.compile(r'(?!<")(\w+):(?!/)')
-    trimmed = [y.replace("\r", "").strip() for y in data.split(",")]
-    jsons = ','.join([js_re.sub(r'"\1":', x, count=1) for x in trimmed])
-    return parse_json(jsons)
-
-
-class UFCTV(Plugin):
-    url_re = re.compile(r"https?://(?:www\.)?ufc\.tv/(channel|video)/.+")
-    video_info_re = re.compile(r"""program\s*=\s*(\{.*?});""", re.DOTALL)
-    channel_info_re = re.compile(r"""g_channel\s*=\s(\{.*?});""", re.DOTALL)
-
-    stream_api_url = "https://www.ufc.tv/service/publishpoint"
-    auth_url = "https://www.ufc.tv/secure/authenticate"
-    auth_schema = validate.Schema(validate.xml_findtext("code"))
-
-    options = PluginOptions({
-        "username": None,
-        "password": None
-    })
-
-    @classmethod
-    def can_handle_url(cls, url):
-        return cls.url_re.match(url) is not None
-
-    def _get_stream_url(self, video_id, vtype="video"):
-        res = http.post(self.stream_api_url, data={
-            "id": video_id,
-            "type": vtype,
-            "format": "json"
-        }, headers={
-            "User-Agent": useragents.IPHONE_6
-        })
-        data = http.json(res)
-        return data.get("path")
-
-    def _get_info(self, url):
-        res = http.get(url)
-        # try to find video info first
-        m = self.video_info_re.search(res.text)
-        if not m:
-            # and channel info if that fails
-            m = self.channel_info_re.search(res.text)
-        return m and js_to_json(m.group(1))
-
-    def _login(self, username, password):
-        res = http.post(self.auth_url, data={
-            "username": username,
-            "password": password,
-            "cookielink": False
-        })
-        login_status = http.xml(res, schema=self.auth_schema)
-        self.logger.debug("Login status for {0}: {1}", username, login_status)
-        if login_status == "loginlocked":
-            self.logger.error("The account {0} has been locked, the password needs to be reset")
-        return login_status == "loginsuccess"
-
-    def _get_streams(self):
-        if self.get_option("username") and self.get_option("password"):
-            self.logger.debug("Attempting login as {0}", self.get_option("username"))
-            if self._login(self.get_option("username"), self.get_option("password")):
-                self.logger.info("Successfully logged in as {0}", self.get_option("username"))
-            else:
-                self.logger.info("Failed to login as {0}", self.get_option("username"))
-
-        video = self._get_info(self.url)
-        if video:
-            self.logger.debug("Found {type}: {name}", **video)
-            surl = self._get_stream_url(video['id'], video.get('type', "video"))
-            surl = surl.replace("_iphone", "")
-            if surl:
-                return HLSStream.parse_variant_playlist(self.session, surl)
-            else:
-                self.logger.error("Could not get stream URL for video: {name} ({id})", **video)
-        else:
-            self.logger.error("Could not find any video info on the page")
-
-__plugin__ = UFCTV
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index fb4927a9..921d1900 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -1272,17 +1272,19 @@ plugin.add_argument(
     """
 )
 plugin.add_argument(
+    "--neulion-username",
     "--ufctv-username",
     metavar="USERNAME",
     help="""
-    The username used to register with ufc.tv.
+    The username used to register with your neulion provider.
     """
 )
 plugin.add_argument(
+    "--neulion-password",
     "--ufctv-password",
     metavar="PASSWORD",
     help="""
-    A ufc.tv account password to use with --ufctv-username.
+    A neulion provider account password to use with --neulion-username.
     """
 )
 plugin.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index bcc162aa..2bc36dc2 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -928,16 +928,16 @@ def setup_plugin_options():
     if bbciplayer_password:
         streamlink.set_plugin_option("bbciplayer", "password", bbciplayer_password)
 
-    if args.ufctv_username:
-        streamlink.set_plugin_option("ufctv", "username", args.ufctv_username)
+    if args.neulion_username:
+        streamlink.set_plugin_option("neulion", "username", args.neulion_username)
 
-    if args.ufctv_username and not args.ufctv_password:
-        ufctv_password = console.askpass("Enter ufc.tv account password: ")
+    if args.neulion_username and not args.neulion_password:
+        neulion_password = console.askpass("Enter ufc.tv account password: ")
     else:
-        ufctv_password = args.ufctv_password
+        neulion_password = args.neulion_password
 
-    if ufctv_password:
-        streamlink.set_plugin_option("ufctv", "password", ufctv_password)
+    if neulion_password:
+        streamlink.set_plugin_option("neulion", "password", neulion_password)
 
     if args.zattoo_email:
         streamlink.set_plugin_option("zattoo", "email", args.zattoo_email)


Test patch:
diff --git a/tests/test_plugin_neulion.py b/tests/test_plugin_neulion.py
new file mode 100644
index 00000000..8cda502b
--- /dev/null
+++ b/tests/test_plugin_neulion.py
@@ -0,0 +1,227 @@
+import unittest
+
+from streamlink.plugins.neulion import Neulion
+from streamlink.plugins.neulion import js_to_json_regex_fallback
+
+
+class TestRegexFallback(unittest.TestCase):
+    def test_js_to_json_regex_fallback(self):
+        regex_test_list = [
+            {
+                "data": """
+                        var currentVideo = {};
+                          currentVideo = {
+                            id: 689825,
+                            name: "All-Access: NBA in Africa 2017",
+                            image: "2017/08/07/689825_es.jpg",
+                            seoName: "2017/08/07/all-access-nba-africa-2017",
+                            description: "It was an exciting week id...",
+                            releaseDate: "2017-08-07 00:00:00.0",
+                            sequence: 689825,
+                            runtime: 262,
+                            category: ""
+                          };
+                        """,
+                "result": {"id": "689825", "name": "All-Access: NBA in Africa 2017"}
+            },
+            {
+                "data": """
+                        var video = {
+                        id: "27687",
+                        name: "5/27 Bologna v Juventus highlight",
+                        description: "",
+                        image: "https://neulionsmbnyc-a.akamaihd.net/u/m...",
+                        type: "video"
+                        };
+                        """,
+                "result": {"id": "27687", "name": "5/27 Bologna v Juventus highlight",  "type": "video"}
+            },
+            {
+                "data": """
+                        var video = {id:"27687", name:"5/27 Bologna v Juventus highlight", description:"", image: "https://neulionsmbnyc-a.akamaihd.net", type: "video"}
+                        """,
+                "result": {"id": "27687", "name": "5/27 Bologna v Juventus highlight", "type": "video"}
+            },
+            {
+                "data": """
+                        var program = {id:"50694", name:"5 najbardziej spektakularnych goli z dystansu w Bundeslidze", description:"Zobacz najlepsze trafienia w lidze niemieckiej ostatnich lat!", image: "https://neulionsmbnyc-a.akamaihd.net/, type: "video"};
+                        """,
+                "result": {"id": "50694", "name": "5 najbardziej spektakularnych goli z dystansu w Bundeslidze", "type": "video"}
+            },
+            {
+                "data": """
+                        <script>
+                          var LOC_SWF = "http://neulionms-a.akamaihd.net/mt/v4/base/site_4/channel.swf";
+                          var LOC_EPG = "http://smb.cdnak.neulion.com/fs/tennischannel/epg/tennis-channel/";
+                          var g_currentDate = "2017/12/12";
+                          var g_channel = {id:"19",name:"Tennis Channel",isLive:true,type:"channel"};
+                        </script>
+                        """,
+                "result": {"id": "19", "name": "Tennis Channel", "type": "channel"}
+            },
+            {
+                "data": """
+                        {id:"68950", name:"Miracle on the Plains", description:"On April 23, 2013, the oaks at Toomer's Corner had to be removed.", image: "https://neulionsmbnyc-a.akamaihd.net/", type: "video"}
+                        """,
+                "result": {"id": "68950", "name": "Miracle on the Plains", "type": "video"}
+            },
+            {
+                "data": """
+                        {id:"68956", name:"When The Garden Was Eden", description:"In the early 1970s. ther: Madison Square Garden. \"When The Garden Was Eden\" (based on the book by", type: "video"};
+                        """,
+                "result": {"id": "68956", "name": "When The Garden Was Eden", "type": "video"}
+            },
+            {
+                "data": """
+                        {
+                            'id': 762769,
+                            'name': 'Phoenix Suns vs. Philadelphia 76ers - Game Highlights',
+                            'image': '2017/12/31/762769_es.jpg',
+                            'seoName': 'channels/highlights/381c9a84-47a9-41ac-a138-761c15afb58f.nba',
+                            'description': 'Phoenix Suns vs. Philadelphia 76ers - Game Highlights',
+                            'releaseDate': '2018-12-31 12:00:00.0',
+                            'sequence': 762769,
+                            'runtime': 301,
+                            'purchaseTypeId': '3',
+                            'category': 'latest'
+                        }
+                        """,
+                "result": {"id": "762769", "name": "Phoenix Suns vs. Philadelphia 76ers - Game Highlights"}
+            },
+            {
+                "data": """
+                        {
+                            id: 762769,
+                            name: "Phoenix Suns vs. Philadelphia 76ers - Game Highlights",
+                            image: "2017/12/31/762769_es.jpg",
+                            seoName: "channels/highlights/381c9a84-47a9-41ac-a138-761c15afb58f.nba",
+                            description: "Phoenix Suns vs. Philadelphia 76ers - Game Highlights",
+                            releaseDate: "2018-12-31 12:00:00.0",
+                            sequence: 762769,
+                            runtime: 301,
+                            purchaseTypeId: '3',
+                            category: "latest"
+                          }
+                        """,
+                "result": {"id": "762769", "name": "Phoenix Suns vs. Philadelphia 76ers - Game Highlights"}
+            },
+            {
+                "data": """
+                        {
+                            id: 696211,
+                            name: "Video Archives: Rockets vs Sonics Game 6 Hakeem 49/25 in 2OT (Pop-up)",
+                            image: "2017/09/12/696211_es.jpg",
+                            seoName: "video-archives-rockets-vs-sonics-game-6-hakeem-49/25-in-2otpop-up",
+                            description: "Rockets vs. Supersonics",
+                            releaseDate: "2017-09-12 19:36:00.0",
+                            sequence: 696211,
+                            runtime: 3513,
+                            purchaseTypeId: '1',
+                            category: "videoarchives"
+                        }
+                        """,
+                "result": {"id": "696211", "name": "Video Archives: Rockets vs Sonics Game 6 Hakeem 49/25 in 2OT (Pop-up)"}
+            },
+        ]
+        for test_dict in regex_test_list:
+            return_data = js_to_json_regex_fallback(test_dict.get("data"))
+            self.assertIsNotNone(return_data)
+            self.assertDictEqual(test_dict.get("result"), return_data)
+
+
+class TestPluginNeulion(unittest.TestCase):
+    """Tests for neulion domains in neulion.py"""
+
+    def test_can_handle_url(self):
+        should_match = [
+            "https://fanpass.co.nz/channel/sky-sport-1",
+            "https://watch.nba.com/channel/nbatvlive",
+            "https://watch.nba.com/video/2017/08/07/all-access-nba-africa-2017",
+            "https://watch.rugbypass.com/game/raiders-at-warriors-on-08122017",
+            "https://www.elevensports.be/channel/eleven-fr",
+            "https://www.elevensports.lu/channel/eleven",
+            "https://www.elevensports.pl/channel/eleven",
+            "https://www.elevensports.sg/channel/eleven",
+            "https://www.elevensports.tw/channel/eleven-zh",
+            "https://www.elevensports.tw/video/5/27-bologna-v-juventus-highlight",
+            "https://www.tennischanneleverywhere.com/channel/tennis-channel",
+            "https://www.ufc.tv/video/ufc-auckland-2017"
+        ]
+        for url in should_match:
+            self.assertTrue(Neulion.can_handle_url(url))
+
+        should_not_match = [
+            "https://www.fanpass.co.nz/channel/sky-sport-1",
+            "https://nba.com/channel/nbatvlive",
+            "https://nba.com/video/2017/08/07/all-access-nba-africa-2017",
+            "https://rugbypass.com/game/raiders-at-warriors-on-08122017",
+            "https://elevensports.be/channel/eleven-fr",
+            "https://elevensports.lu/channel/eleven",
+            "https://elevensports.pl/channel/eleven",
+            "https://elevensports.sg/channel/eleven",
+            "https://elevensports.tw/channel/eleven-zh",
+            "https://elevensports.tw/video/5/27-bologna-v-juventus-highlight",
+            "https://tennischanneleverywhere.com/channel/tennis-channel",
+            "https://ufc.tv/video/ufc-auckland-2017"
+        ]
+        for url in should_not_match:
+            self.assertFalse(Neulion.can_handle_url(url))
+
+    def test_domain(self):
+        regex_test_list = [
+            {
+                "data": "https://fanpass.co.nz/channel/sky-sport-1",
+                "result": "fanpass.co.nz"
+            },
+            {
+                "data": "https://watch.nba.com/video/2017/08/07/all-access-nba-africa-2017",
+                "result": "watch.nba.com"
+            },
+            {
+                "data": "https://watch.rugbypass.com/game/raiders-at-warriors-on-08122017",
+                "result": "watch.rugbypass.com"
+            },
+            {
+                "data": "https://www.elevensports.be/channel/eleven-fr",
+                "result": "www.elevensports.be"
+            },
+            {
+                "data": "https://www.tennischanneleverywhere.com/channel/tennis-channel",
+                "result": "www.tennischanneleverywhere.com"
+            }
+        ]
+        for test_dict in regex_test_list:
+            test_class = Neulion(test_dict.get("data"))
+            _domain = test_class._domain
+            self.assertIsNotNone(_domain)
+            self.assertEqual(test_dict.get("result"), _domain)
+
+    def test_vtype(self):
+        regex_test_list = [
+            {
+                "data": "https://fanpass.co.nz/channel/sky-sport-1",
+                "result": "channel"
+            },
+            {
+                "data": "https://watch.nba.com/video/2017/08/07/all-access-nba-africa-2017",
+                "result": "video"
+            },
+            {
+                "data": "https://watch.rugbypass.com/game/raiders-at-warriors-on-08122017",
+                "result": "game"
+            },
+            {
+                "data": "https://www.elevensports.be/channel/eleven-fr",
+                "result": "channel"
+            },
+            {
+                "data": "https://www.tennischanneleverywhere.com/channel/tennis-channel",
+                "result": "channel"
+            }
+        ]
+
+        for test_dict in regex_test_list:
+            test_class = Neulion(test_dict.get("data"))
+            _vtype = test_class._vtype
+            self.assertIsNotNone(_vtype)
+            self.assertEqual(test_dict.get("result"), _vtype)