Repository: streamlink/streamlink (python)

Issue:
[Plugin Request] Vlive.tv
<!--
Thanks for requesting a plugin!
USE THE TEMPLATE. Otherwise your plugin request may be rejected.

First, see the contribution guidelines and plugin request requirements:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

Plugin requests which fall into the categories we will not implement will be closed immediately.

Also check the list of open and closed plugin requests:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+request%22

Please see the text preview to avoid unnecessary formatting errors.
-->


## Plugin Request

<!-- Replace [ ] with [x] in order to check the box -->
- [x] This is a plugin request and I have read the contribution guidelines and plugin request requirements.


### Description
This is a live streaming site.
This site was originally mainly for idols who want to interact with fans, but now it has grown more, many people have registered and broadcast a number of more diverse programs.
<!-- Explain the plugin and site as clearly as you can. What is the site about? Who runs it? What content does it provide? What value does it bring to Streamlink? Etc. -->


### Example stream URLs

<!-- Example URLs for streams are required. Plugin requests which do not have example URLs will be closed. -->

1. https://www.vlive.tv/ (Home page)
2. https://www.vlive.tv/upcoming (Upcoming broadcasts will be show here)


### Additional comments, screenshots, etc.
This site has no live streaming 24/7 so you can visit the homepage and look down, sometimes seeing some people broadcasting there (IMG: https://i.imgur.com/rHBMmli.jpg).

Or visit this page to see upcoming shows: https://www.vlive.tv/upcoming

Hope you can support this page, thanks so much.

[Love Streamlink? Please consider supporting our collective. Thanks!](https://opencollective.com/streamlink/donate)


Pull request description:
plugins.vlive: Add support for V LIVE live streams
Adds support for live streams on https://www.vlive.tv/
Fixes #2544

Their (edited) VODs are embeded from Naver and work completely differently, so they're not supported.

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index b88febc7..027cccaa 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -267,6 +267,7 @@ vimeo                   vimeo.com            Yes   Yes   Password-protected vide
 vinhlongtv              thvli.vn             Yes   No    Streams are geo-restricted to Vietnam
 viutv                   viu.tv               Yes   No    Streams are geo-restricted to Hong Kong
 vk                      vk.com               Yes   Yes
+vlive                   vlive.tv             Yes   No    Embedded Naver VODs are not supported.
 vrtbe                   vrt.be/vrtnu         Yes   Yes
 vtvgo                   vtvgo.vn             Yes   No
 webcast_india_gov       webcast.gov.in       Yes   No    You can use #Channel to indicate CH number.
diff --git a/src/streamlink/plugins/playtv.py b/src/streamlink/plugins/playtv.py
index 968949ac..4d6056a5 100644
--- a/src/streamlink/plugins/playtv.py
+++ b/src/streamlink/plugins/playtv.py
@@ -1,5 +1,3 @@
-import base64
-import json
 import re
 
 from streamlink.plugin import Plugin
@@ -7,17 +5,11 @@ from streamlink.plugin.api import validate
 from streamlink.stream import HDSStream, HLSStream
 
 
-def jwt_decode(token):
-     info, payload, sig = token.split(".")
-     data = base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4))
-     return json.loads(data)
-
-
 class PlayTV(Plugin):
-    FORMATS_URL = 'https://playtv.fr/player/initialize/{0}/'
-    API_URL = 'https://playtv.fr/player/play/{0}/?format={1}&language={2}&bitrate={3}'
+    FORMATS_URL = 'http://playtv.fr/player/initialize/{0}/'
+    API_URL = 'http://playtv.fr/player/play/{0}/?format={1}&language={2}&bitrate={3}'
 
-    _url_re = re.compile(r'https?://(?:playtv\.fr/television|(:?\w+\.)?play\.tv/live-tv/\d+)/(?P<channel>[^/]+)/?')
+    _url_re = re.compile(r'http://(?:playtv\.fr/television|play\.tv/live-tv/\d+)/(?P<channel>[^/]+)/?')
 
     _formats_schema = validate.Schema({
         'streams': validate.any(
@@ -35,13 +27,9 @@ class PlayTV(Plugin):
             }
         )
     })
-
-    _api_schema = validate.Schema(
-        validate.transform(lambda x: jwt_decode(x)),
-        {
-            'url': validate.url()
-        }
-    )
+    _api_schema = validate.Schema({
+        'url': validate.url()
+    })
 
     @classmethod
     def can_handle_url(cls, url):
@@ -69,7 +57,7 @@ class PlayTV(Plugin):
                         continue
                     api_url = self.API_URL.format(channel, protocol, language, bitrate['value'])
                     res = self.session.http.get(api_url)
-                    video_url = self._api_schema.validate(res.text)['url']
+                    video_url = self.session.http.json(res, schema=self._api_schema)['url']
                     bs = '{0}k'.format(bitrate['value'])
 
                     if protocol == 'hls':
diff --git a/src/streamlink/plugins/vlive.py b/src/streamlink/plugins/vlive.py
new file mode 100644
index 00000000..71a3f645
--- /dev/null
+++ b/src/streamlink/plugins/vlive.py
@@ -0,0 +1,55 @@
+import re
+import json
+
+from streamlink.plugin import Plugin, PluginError
+from streamlink.stream import HLSStream
+
+
+class Vlive(Plugin):
+    _url_re = re.compile(r"https?://(?:www.)vlive\.tv/video/(\d+)")
+    _video_status = re.compile(r'oVideoStatus = (.+)<', re.DOTALL)
+    _video_init_url = "https://www.vlive.tv/video/init/view"
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    @property
+    def video_id(self):
+        return self._url_re.match(self.url).group(1)
+
+    def _get_streams(self):
+        vinit_req = self.session.http.get(self._video_init_url,
+                                          params=dict(videoSeq=self.video_id),
+                                          headers=dict(referer=self.url))
+        if vinit_req.status_code != 200:
+            raise PluginError('Could not get video init page (HTTP Status {})'
+                              .format(vinit_req.status_code))
+
+        video_status_js = self._video_status.search(vinit_req.text)
+        if not video_status_js:
+            raise PluginError('Could not find video status information!')
+
+        video_status = json.loads(video_status_js.group(1))
+
+        if video_status['viewType'] == 'vod':
+            raise PluginError('VODs are not supported')
+
+        if 'liveStreamInfo' not in video_status:
+            raise PluginError('Stream is offline')
+
+        stream_info = json.loads(video_status['liveStreamInfo'])
+
+        streams = dict()
+        # All "resolutions" have a variant playlist with only one entry, so just combine them
+        for i in stream_info['resolutions']:
+            res_streams = HLSStream.parse_variant_playlist(self.session, i['cdnUrl'])
+            if len(res_streams.values()) > 1:
+                self.logger.warning('More than one stream in variant playlist, using first entry!')
+
+            streams[i['name']] = res_streams.popitem()[1]
+
+        return streams
+
+
+__plugin__ = Vlive


Test patch:
diff --git a/tests/plugins/test_vlive.py b/tests/plugins/test_vlive.py
new file mode 100644
index 00000000..31f7c8f8
--- /dev/null
+++ b/tests/plugins/test_vlive.py
@@ -0,0 +1,23 @@
+import unittest
+
+import pytest
+
+from streamlink.plugins.vlive import Vlive
+
+
+class TestPluginVlive:
+    valid_urls = [
+        ("https://www.vlive.tv/video/156824",),
+    ]
+    invalid_urls = [
+        ("https://www.vlive.tv/events/2019vheartbeat?lang=en",),
+        ("https://twitch.tv/",)
+    ]
+
+    @pytest.mark.parametrize(["url"], valid_urls)
+    def test_can_handle_url(self, url):
+        assert Vlive.can_handle_url(url), "url should be handled"
+
+    @pytest.mark.parametrize(["url"], invalid_urls)
+    def test_can_handle_url_negative(self, url):
+        assert not Vlive.can_handle_url(url), "url should not be handled"