Repository: streamlink/streamlink
Pull request #2574 (2019-08-03 09:38:47)

plugins.linelive: new plugin for LINE LIVE (live.line.me)
 #2573


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 96076599..484121c1 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -106,6 +106,7 @@ itvplayer               itv.com/itvplayer    Yes   Yes   Streams may be geo-rest
 kanal7                  - kanal7.com         Yes   No
                         - tvt.tv.tr
 kingkong                kingkong.com.tw      Yes   --
+linelive                live.line.me         Yes   --
 live_russia_tv          live.russia.tv       Yes   --
 liveedu                 - liveedu.tv         Yes   --    Some streams require a login.
                         - livecoding.tv
diff --git a/src/streamlink/plugins/linelive.py b/src/streamlink/plugins/linelive.py
new file mode 100644
index 00000000..24a2c33b
--- /dev/null
+++ b/src/streamlink/plugins/linelive.py
@@ -0,0 +1,48 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+
+
+class LineLive(Plugin):
+    _url_re = re.compile(r"""
+        https?://live\.line\.me
+        /channels/(?P<channel>\d+)
+        /broadcast/(?P<broadcast>\d+)
+    """, re.VERBOSE)
+
+    _api_url = "https://live-api.line-apps.com/app/v3.2/channel/{0}/broadcast/{1}/player_status"
+
+    _player_status_schema = validate.Schema(
+        {
+            "liveStatus": validate.text,
+            "liveHLSURLs": validate.any(None, {
+                "720": validate.any(None, validate.url(scheme="http", path=validate.endswith(".m3u8"))),
+                "480": validate.any(None, validate.url(scheme="http", path=validate.endswith(".m3u8"))),
+                "360": validate.any(None, validate.url(scheme="http", path=validate.endswith(".m3u8"))),
+                "240": validate.any(None, validate.url(scheme="http", path=validate.endswith(".m3u8"))),
+                "144": validate.any(None, validate.url(scheme="http", path=validate.endswith(".m3u8"))),
+            })
+        }
+     )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def _get_streams(self):
+        match = self._url_re.match(self.url)
+        channel = match.group("channel")
+        broadcast = match.group("broadcast")
+        res = self.session.http.get(self._api_url.format(channel, broadcast))
+        json = self.session.http.json(res, schema=self._player_status_schema)
+        if json["liveStatus"] != "LIVE":
+            return
+        for stream in json["liveHLSURLs"]:
+            url = json["liveHLSURLs"][stream]
+            if url != None:
+                yield "{0}p".format(stream), HLSStream(self.session, url)
+
+
+__plugin__ = LineLive
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 49cc188d..56fb5be9 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -104,7 +104,7 @@ def create_output(plugin):
 
         if args.player_fifo:
             pipename = "streamlinkpipe-{0}".format(os.getpid())
-            log.info("Creating pipe {0}", pipename)
+            log.info("Creating pipe {0}".format(pipename))
 
             try:
                 namedpipe = NamedPipe(pipename)
@@ -118,7 +118,7 @@ def create_output(plugin):
         if args.record:
             record = check_file_output(args.record, args.force)
 
-        log.info("Starting player: {0}", args.player)
+        log.info("Starting player: {0}".format(args.player))
 
         out = PlayerOutput(args.player, args=args.player_args,
                            quiet=not args.verbose_player,
@@ -192,7 +192,7 @@ def output_stream_http(plugin, initial_streams, external=False, port=0):
                                        title=title)
 
         try:
-            log.info("Starting player: {0}", args.player)
+            log.info("Starting player: {0}".format(args.player))
             if player:
                 player.open()
         except OSError as err:
@@ -226,15 +226,15 @@ def output_stream_http(plugin, initial_streams, external=False, port=0):
                     sleep(10)
                     continue
             except PluginError as err:
-                log.error(u"Unable to fetch new streams: {0}", err)
+                log.error(u"Unable to fetch new streams: {0}".format(err))
                 continue
 
             try:
-                log.info("Opening stream: {0} ({1})", stream_name,
-                         type(stream).shortname())
+                log.info("Opening stream: {0} ({1})".format(stream_name,
+                                                            type(stream).shortname()))
                 stream_fd, prebuffer = open_stream(stream)
             except StreamError as err:
-                log.error("{0}", err)
+                log.error("{0}".format(err))
 
         if stream_fd and prebuffer:
             log.debug("Writing stream to player")
@@ -258,7 +258,7 @@ def output_stream_passthrough(plugin, stream):
                           title=title)
 
     try:
-        log.info("Starting player: {0}", args.player)
+        log.info("Starting player: {0}".format(args.player))
         output.open()
     except OSError as err:
         console.exit("Failed to start player: {0} ({1})", args.player, err)
@@ -309,7 +309,8 @@ def output_stream(plugin, stream):
             success_open = True
             break
         except StreamError as err:
-            log.error("Try {0}/{1}: Could not open stream {2} ({3})", i + 1, args.retry_open, stream, err)
+            log.error("Try {0}/{1}: Could not open stream {2} ({3})".format(
+                i + 1, args.retry_open, stream, err))
 
     if not success_open:
         console.exit("Could not open stream {0}, tried {1} times, exiting", stream, args.retry_open)
@@ -432,8 +433,8 @@ def handle_stream(plugin, streams, stream_name):
             stream_type = type(stream).shortname()
 
             if stream_type in args.player_passthrough and not file_output:
-                log.info("Opening stream: {0} ({1})", stream_name,
-                         stream_type)
+                log.info("Opening stream: {0} ({1})".format(stream_name,
+                                                            stream_type))
                 success = output_stream_passthrough(plugin, stream)
             elif args.player_external_http:
                 return output_stream_http(plugin, streams, external=True,
@@ -441,8 +442,8 @@ def handle_stream(plugin, streams, stream_name):
             elif args.player_continuous_http and not file_output:
                 return output_stream_http(plugin, streams)
             else:
-                log.info("Opening stream: {0} ({1})", stream_name,
-                         stream_type)
+                log.info("Opening stream: {0} ({1})".format(stream_name,
+                                                            stream_type))
 
                 success = output_stream(plugin, stream)
 
@@ -464,12 +465,12 @@ def fetch_streams_with_retry(plugin, interval, count):
     try:
         streams = fetch_streams(plugin)
     except PluginError as err:
-        log.error(u"{0}", err)
+        log.error(u"{0}".format(err))
         streams = None
 
     if not streams:
         log.info("Waiting for streams, retrying every {0} "
-                 "second(s)", interval)
+                 "second(s)".format(interval))
     attempts = 0
 
     while not streams:
@@ -480,7 +481,7 @@ def fetch_streams_with_retry(plugin, interval, count):
         except FatalPluginError as err:
             raise
         except PluginError as err:
-            log.error(u"{0}", err)
+            log.error(u"{0}".format(err))
 
         if count > 0:
             attempts += 1
@@ -548,8 +549,8 @@ def handle_url():
     try:
         plugin = streamlink.resolve_url(args.url)
         setup_plugin_options(streamlink, plugin)
-        log.info("Found matching plugin {0} for URL {1}",
-                 plugin.module, args.url)
+        log.info("Found matching plugin {0} for URL {1}".format(
+                 plugin.module, args.url))
 
         plugin_args = []
         for parg in plugin.arguments:
@@ -590,7 +591,7 @@ def handle_url():
         validstreams = format_valid_streams(plugin, streams)
         for stream_name in args.stream:
             if stream_name in streams:
-                log.info("Available streams: {0}", validstreams)
+                log.info("Available streams: {0}".format(validstreams))
                 handle_stream(plugin, streams, stream_name)
                 return
 
@@ -657,7 +658,7 @@ def load_plugins(dirs):
             streamlink.load_plugins(directory)
         else:
             log.warning("Plugin path {0} does not exist or is not "
-                        "a directory!", directory)
+                        "a directory!".format(directory))
 
 
 def setup_args(parser, config_files=[], ignore_unknown=False):
@@ -901,7 +902,7 @@ def setup_plugin_options(session, plugin):
                     for rparg in plugin.arguments.requires(parg.name):
                         required[rparg.name] = rparg
                 except RuntimeError:
-                    console.logger.error("{0} plugin has a configuration error and the arguments "
+                    log.error("{0} plugin has a configuration error and the arguments "
                                          "cannot be parsed".format(pname))
                     break
     if required:
@@ -962,8 +963,8 @@ def check_version(force=False):
                  "available!".format(latest_version))
         cache.set("version_info_printed", True, (60 * 60 * 6))
     elif force:
-        log.info("Your Streamlink version ({0}) is up to date!",
-                 installed_version)
+        log.info("Your Streamlink version ({0}) is up to date!".format(
+                 installed_version))
 
     if force:
         sys.exit()


Test patch:
diff --git a/tests/plugins/test_linelive.py b/tests/plugins/test_linelive.py
new file mode 100644
index 00000000..0928ed01
--- /dev/null
+++ b/tests/plugins/test_linelive.py
@@ -0,0 +1,21 @@
+import unittest
+
+from streamlink.plugins.linelive import LineLive
+
+
+class TestPluginLineLive(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'http://live.line.me/channels/123/broadcast/12345678',
+            'https://live.line.me/channels/123/broadcast/12345678',
+        ]
+        for url in should_match:
+            self.assertTrue(LineLive.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'https://example.com/index.html',
+            'https://live.line.me/channels/123/upcoming/12345678',
+        ]
+        for url in should_not_match:
+            self.assertFalse(LineLive.can_handle_url(url))