Repository: streamlink/streamlink

Issue:
[Plugin Request] pixiv Sketch
- [ ] This is a bug report.
- [x] This is a feature request.
- [ ] This is a plugin (improvement) request.
- [x] I have read the contribution guidelines.

I'm requesting a plugin for pixiv sketch 

its streamed via HLS it serves a generated .m3u8(for 30ish seconds) along with its 3ish second chunk every second or so

| xhr | https://hls4.pixivsketch.net/2018022718/15197228728388594847a96103176027c0811ead7c5788b1297/4000000_1920x1080/506579247.ts
| xhr | https://hls4.pixivsketch.net/2018022718/15197228728388594847a96103176027c0811ead7c5788b1297/4000000_1920x1080/index.m3u8

hls4.pixivsketch.net: fixed gen'd server*
2018022718: stream start time (YYYYMMDDHH)
1519...1297: fixed UID*
4000000_1920x1080: video quality (low = 365000_480x270)

*Until the api regens (The streamers internet drops)

a look at the .m3u8
```
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-MEDIA-SEQUENCE:506579247
#EXT-X-TARGETDURATION:6
#EXT-X-START:TIME-OFFSET=-3.00,PRECISE=NO
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:21.000741924+09:00
#EXTINF:3.000000,
506579247.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:24.000744924+09:00
#EXTINF:3.000000,
506579248.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:27.000747924+09:00
#EXTINF:3.000000,
506579249.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:30.000750924+09:00
#EXTINF:3.000000,
506579250.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:33.000753924+09:00
#EXTINF:3.000000,
506579251.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:36.000756924+09:00
#EXTINF:3.000000,
506579252.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:39.000759924+09:00
#EXTINF:3.000000,
506579253.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:42.000762924+09:00
#EXTINF:3.000000,
506579254.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:45.000765924+09:00
#EXTINF:3.000000,
506579255.ts
#EXT-X-PROGRAM-DATE-TIME:2018-02-27T22:22:48.000768924+09:00
#EXTINF:3.000000,
506579256.ts
```
**Update** found hlsvariant://... and it works but a proper plugin is appreciated


Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/pixiv.py b/src/streamlink/plugins/pixiv.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/plugins/pixiv.py
@@ -0,0 +1,134 @@
+# -*- coding: utf-8 -*-
+import re
+
+from streamlink import PluginError
+from streamlink.compat import urljoin
+from streamlink.exceptions import NoStreamsError
+from streamlink.plugin import Plugin
+from streamlink.plugin import 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
+
+
+class Pixiv(Plugin):
+    """Plugin for https://sketch.pixiv.net/lives"""
+
+    _url_re = re.compile(r"https?://sketch\.pixiv\.net/[^/]+(?P<videopage>/lives/\d+)?")
+
+    _videopage_re = re.compile(r"""["']live-button["']><a\shref=["'](?P<path>[^"']+)["']""")
+    _data_re = re.compile(r"""<script\sid=["']state["']>[^><{]+(?P<data>{[^><]+})</script>""")
+    _post_key_re = re.compile(r"""name=["']post_key["']\svalue=["'](?P<data>[^"']+)["']""")
+
+    _data_schema = validate.Schema(
+        validate.all(
+            validate.transform(_data_re.search),
+            validate.any(
+                None,
+                validate.all(
+                    validate.get("data"),
+                    validate.transform(parse_json),
+                    validate.get("context"),
+                    validate.get("dispatcher"),
+                    validate.get("stores"),
+                )
+            )
+        )
+    )
+
+    login_url_get = "https://accounts.pixiv.net/login"
+    login_url_post = "https://accounts.pixiv.net/api/login"
+
+    options = PluginOptions({
+        "username": None,
+        "password": None
+    })
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def find_videopage(self):
+        self.logger.debug("Not a videopage")
+        res = http.get(self.url)
+
+        m = self._videopage_re.search(res.text)
+        if not m:
+            self.logger.debug("No stream path, stream might be offline or invalid url.")
+            raise NoStreamsError(self.url)
+
+        path = m.group("path")
+        self.logger.debug("Found new path: {0}".format(path))
+        return urljoin(self.url, path)
+
+    def _login(self, username, password):
+        res = http.get(self.login_url_get)
+        m = self._post_key_re.search(res.text)
+        if not m:
+            raise PluginError("Missing post_key, no login posible.")
+
+        post_key = m.group("data")
+        data = {
+            "lang": "en",
+            "source": "sketch",
+            "post_key": post_key,
+            "pixiv_id": username,
+            "password": password,
+        }
+
+        res = http.post(self.login_url_post, data=data)
+        res = http.json(res)
+
+        if res["body"].get("success"):
+            return True
+        else:
+            return False
+
+    def _get_streams(self):
+        http.headers = {"User-Agent": useragents.FIREFOX}
+
+        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}".format(login_username))
+            if self._login(login_username, login_password):
+                self.logger.info("Successfully logged in as {0}".format(login_username))
+            else:
+                self.logger.info("Failed to login as {0}".format(login_username))
+
+        videopage = self._url_re.match(self.url).group("videopage")
+        if not videopage:
+            self.url = self.find_videopage()
+
+        data = http.get(self.url, schema=self._data_schema)
+
+        if not data.get("LiveStore"):
+            self.logger.debug("No video url found, stream might be offline.")
+            return
+
+        data = data["LiveStore"]["lives"]
+
+        # get the unknown user-id
+        for _key in data.keys():
+            video_data = data.get(_key)
+
+        owner = video_data["owner"]
+        self.logger.info("Owner ID: {0}".format(owner["user_id"]))
+        self.logger.debug("HLS URL: {0}".format(owner["hls_movie"]))
+        for n, s in HLSStream.parse_variant_playlist(self.session, owner["hls_movie"]).items():
+            yield n, s
+
+        performers = video_data.get("performers")
+        if performers:
+            for p in performers:
+                self.logger.info("CO-HOST ID: {0}".format(p["user_id"]))
+                hls_url = p["hls_movie"]
+                self.logger.debug("HLS URL: {0}".format(hls_url))
+                for n, s in HLSStream.parse_variant_playlist(self.session, hls_url).items():
+                    _n = "{0}_{1}".format(n, p["user_id"])
+                    yield _n, s
+
+
+__plugin__ = Pixiv
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -1363,6 +1363,20 @@ def hours_minutes_seconds(value):
     A afreecatv.com account password to use with --afreeca-username.
     """
 )
+plugin.add_argument(
+    "--pixiv-username",
+    metavar="USERNAME",
+    help="""
+    The email/username used to register with pixiv.net
+    """
+)
+plugin.add_argument(
+    "--pixiv-password",
+    metavar="PASSWORD",
+    help="""
+    A pixiv.net account password to use with --pixiv-username
+    """
+)
 
 # Deprecated options
 stream.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -964,6 +964,17 @@ def setup_plugin_options():
     if afreeca_password:
         streamlink.set_plugin_option("afreeca", "password", afreeca_password)
 
+    if args.pixiv_username:
+        streamlink.set_plugin_option("pixiv", "username", args.pixiv_username)
+
+    if args.pixiv_username and not args.pixiv_password:
+        pixiv_password = console.askpass("Enter pixiv account password: ")
+    else:
+        pixiv_password = args.pixiv_password
+
+    if pixiv_password:
+        streamlink.set_plugin_option("pixiv", "password", pixiv_password)
+
 
 def check_root():
     if hasattr(os, "getuid"):


Test patch:
diff --git a/tests/test_plugin_pixiv.py b/tests/test_plugin_pixiv.py
new file mode 100644
--- /dev/null
+++ b/tests/test_plugin_pixiv.py
@@ -0,0 +1,19 @@
+import unittest
+
+from streamlink.plugins.pixiv import Pixiv
+
+
+class TestPluginPixiv(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'https://sketch.pixiv.net/@exampleuser',
+            'https://sketch.pixiv.net/@exampleuser/lives/000000000000000000',
+        ]
+        for url in should_match:
+            self.assertTrue(Pixiv.can_handle_url(url))
+
+        should_not_match = [
+            'https://sketch.pixiv.net',
+        ]
+        for url in should_not_match:
+            self.assertFalse(Pixiv.can_handle_url(url))