Repository: streamlink/streamlink
Pull request #633 (2017-02-28 03:27:39)

plugins.showroom: add support for showroom-live.com live streams
Add support for streaming live broadcasts from the Japanese streaming service showroom-live.com. Showroom provides both HLS and RTMP sources, but as the HLS sources are highly unstable, this plugin only handles RTMP.

Example URLs:
- https://www.showroom-live.com/kimi_dare
- https://www.showroom-live.com/room/profile?room_id=75207

As mentioned in issue #627 

_Tested with mpv 0.23.0, vlc 3.0.0, ffplay (recent build and 2.8.10-0ubuntu0.16.04.1)_

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/633?src=pr&el=h1) Report
> Merging [#633](https://codecov.io/gh/streamlink/streamlink/pull/633?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/3fcc84c34fcbf52da5f6ccca197355706407444d?src=pr&el=desc) will **decrease** coverage by `-0.08%`.
> The diff coverage is `42.66%`.


```diff
@@            Coverage Diff             @@
##           master     #633      +/-   ##
==========================================
- Coverage    48.5%   48.42%   -0.08%     
==========================================
  Files          66       67       +1     
  Lines        5567     5642      +75     
==========================================
+ Hits         2700     2732      +32     
- Misses       2867     2910      +43
```

------

[Continue to review full report at Codecov](https://codecov.io/gh/streamlink/streamlink/pull/633?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/633?src=pr&el=footer). Last update [3fcc84c...971840a](https://codecov.io/gh/streamlink/streamlink/compare/3fcc84c34fcbf52da5f6ccca197355706407444d...971840aac5c6d2fd797dad7156eee045cb2c329f?el=footer&src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).
gravyboat: Other than my note above this looks good, thanks for the tests!
bastimeyer: @wlerin 
If you rebase your branch, you can remove the unnecessary commits 76c1e00 and fc6179c instead of reverting 76c1e00, which looks like a merging mistake from the master branch. Just force push it again then.
wlerin: @bastimeyer I'm not completely certain how to do that. My original attempt at rebasing the project is what created 76c1e00 in the first place.
bastimeyer: It's fine 😄
The commits of the PR will get squashed anyway. But yeah, this looks much cleaner.

Patch:
diff --git a/docs/cli.rst b/docs/cli.rst
index 5cee95cc..46c5f0ed 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -284,6 +284,26 @@ Accessing a stream that requires extra parameters to be passed along
 
     $ streamlink "rtmp://streaming.server.net/playpath live=1 swfVfy=http://server.net/flashplayer.swf"
 
+When passing parameters to the built-in stream plugins the values will either be treated as plain
+strings, as is the case in the above example for ``swfVry``, or they will be interpreted as Python literals. For
+example you can pass a Python dict or Python list as one of the parameters.
+
+.. code-block:: console
+
+    $ streamlink "rtmp://streaming.server.net/playpath conn=['B:1', 'S:authMe', 'O:1', 'NN:code:1.23', 'NS:flag:ok', 'O:0']"
+    $ streamlink "hls://streaming.server.net/playpath params={'token': 'magicToken'}"
+
+In the above examples ``conn`` will be passed as the Python list:
+
+.. code-block:: python
+
+    ['B:1', 'S:authMe', 'O:1', 'NN:code:1.23', 'NS:flag:ok', 'O:0']
+
+and ``params`` will be passed as the Python dict:
+
+.. code-block:: python
+
+    {'token': 'magicToken'}
 
 Most streaming technologies simply requires you to pass a HTTP URL, this is
 a Adobe HDS stream:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index dfedf2a2..fda7b868 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -80,6 +80,8 @@ expressen           expressen.se         Yes   Yes
 filmon              filmon.com           Yes   Yes   Only SD quality streams.
 filmon_us           filmon.us            Yes   Yes
 foxtr               fox.com.tr           Yes   No
+funimationnow       - funimation.com     --    Yes
+                    - funimationnow.uk
 furstream           furstre.am           Yes   No
 gaminglive          gaminglive.tv        Yes   Yes
 gomexp              gomexp.com           Yes   No
@@ -131,6 +133,7 @@ schoolism           schoolism.com        --    Yes   Requires a login and a subs
 seemeplay           seemeplay.ru         Yes   Yes
 seetv               seetv.tv             Yes   No    Streams that are embedded from other sites will not work.
 servustv            servustv.com         ?     ?
+showroom            showroom-live.com    Yes   No    Only RTMP streams are available.
 speedrunslive       speedrunslive.com    Yes   --    URL forwarder to Twitch channels.
 sportal             sportal.bg           Yes   No
 sportschau          sportschau.de        Yes   No
diff --git a/src/streamlink/plugins/funimationnow.py b/src/streamlink/plugins/funimationnow.py
new file mode 100644
index 00000000..4b2464e5
--- /dev/null
+++ b/src/streamlink/plugins/funimationnow.py
@@ -0,0 +1,169 @@
+from __future__ import print_function
+
+import random
+import re
+
+from streamlink.plugin import Plugin, PluginOptions
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.stream import HLSStream
+from streamlink.stream import HTTPStream
+from streamlink.stream.ffmpegmux import MuxedStream
+
+
+class Experience(object):
+    api_base = "https://prod-api-funimationnow.dadcdigital.com/api"
+    show_api_url = api_base+"/source/catalog/title/experience/{experience_id}/"
+    sources_api_url = api_base+"/source/catalog/video/{experience_id}/signed/"
+    languages = ["english", "japanese"]
+    alphas = ["uncut", "simulcast"]
+
+    def __init__(self, experience_id):
+        """
+        :param experience_id: starting experience_id, may be changed later
+        """
+        self.experience_id = experience_id
+        self._language = None
+        self.cache = {}
+
+    @property
+    def pinst_id(self):
+        return ''.join([
+            random.choice("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(8)
+        ])
+
+    def _update(self):
+        api_url = self.show_api_url.format(experience_id=self.experience_id)
+        res = http.get(api_url)
+        data = http.json(res)
+        self.cache[self.experience_id] = data
+
+    @property
+    def show_info(self):
+        if self.experience_id not in self.cache:
+            self._update()
+        return self.cache.get(self.experience_id)
+
+    @property
+    def episode_info(self):
+        """
+        Search for the episode with the requested experience Id
+        :return:
+        """
+        for season in self.show_info["seasons"]:
+            for episode in season["episodes"]:
+                for lang in episode["languages"].values():
+                    for alpha in lang["alpha"].values():
+                        if alpha["experienceId"] == self.experience_id:
+                            return episode
+
+    @property
+    def language(self):
+        for language, lang_data in self.episode_info["languages"].items():
+            for alpha in lang_data["alpha"].values():
+                if alpha["experienceId"] == self.experience_id:
+                    return language
+
+    @property
+    def language_code(self):
+        return {"english": "eng", "japanese": "jpn"}[self.language]
+
+    def set_language(self, language):
+        if language in self.episode_info["languages"]:
+            for alpha in self.episode_info["languages"][language]["alpha"].values():
+                self.experience_id = alpha["experienceId"]
+
+    def _get_alpha(self):
+        for lang_data in self.episode_info["languages"].values():
+            for alpha in lang_data["alpha"].values():
+                if alpha["experienceId"] == self.experience_id:
+                    return alpha
+
+    def subtitles(self):
+        alpha = self._get_alpha()
+        for src in alpha["sources"]:
+            return src["textTracks"]
+
+    def sources(self):
+        """
+        Get the sources for a given experience_id, which is tied to a specific language
+        :param experience_id: int; video content id
+        :return: sources dict
+        """
+        api_url = self.sources_api_url.format(experience_id=self.experience_id)
+        res = http.get(api_url, params=dict(pinstId=self.pinst_id))
+        return http.json(res)
+
+
+class FunimationNow(Plugin):
+    options = PluginOptions({
+        "language": "english",
+        "mux_subtitles": False
+    })
+    url_re = re.compile(r"""
+        https?://(?:www\.)funimation(.com|now.uk)
+    """, re.VERBOSE)
+    experience_id_re = re.compile(r"/player/(\d+)")
+    mp4_quality = "480p"
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        http.headers = {"User-Agent": useragents.CHROME}
+        res = http.get(self.url)
+
+        rlanguage = self.get_option("language")
+
+        id_m = self.experience_id_re.search(res.text)
+        experience_id = id_m and int(id_m.group(1))
+        if experience_id:
+            self.logger.debug("Found experience ID: {0}", experience_id)
+            exp = Experience(experience_id)
+
+            self.logger.debug("Found episode: {0}", exp.episode_info["episodeTitle"])
+            self.logger.debug("  has languages: {0}", ", ".join(exp.episode_info["languages"].keys()))
+            self.logger.debug("  requested language: {0}", rlanguage)
+            self.logger.debug("  current language:   {0}", exp.language)
+            if rlanguage != exp.language:
+                self.logger.debug("switching language to: {0}", rlanguage)
+                exp.set_language(rlanguage)
+                if exp.language != rlanguage:
+                    self.logger.warning("Requested language {0} is not available, continuing with {1}",
+                                        rlanguage, exp.language)
+                else:
+                    self.logger.debug("New experience ID: {0}", exp.experience_id)
+
+            subtitles = None
+            stream_metadata = {}
+            disposition = {}
+            for subtitle in exp.subtitles():
+                self.logger.info("Subtitles: {0}", subtitle["src"])
+                if subtitle["src"].endswith(".vtt") or subtitle["src"].endswith(".srt"):
+                    sub_lang = {"en": "eng", "ja": "jpn"}[subtitle["language"]]
+                    # pick the first suitable subtitle stream
+                    subtitles = subtitles or HTTPStream(self.session, subtitle["src"])
+                    stream_metadata["s:s:0"] = ["language={0}".format(sub_lang)]
+                stream_metadata["s:a:0"] = ["language={0}".format(exp.language_code)]
+
+            for item in exp.sources()["items"]:
+                url = item["src"]
+                if ".m3u8" in url:
+                    for q, s in HLSStream.parse_variant_playlist(self.session, url).items():
+                        if self.get_option("mux_subtitles") and subtitles:
+                            yield q, MuxedStream(self.session, s, subtitles, metadata=stream_metadata, disposition=disposition)
+                        else:
+                            yield q, s
+                elif ".mp4" in url:
+                    # TODO: fix quality
+                    s = HTTPStream(self.session, url)
+                    if self.get_option("mux_subtitles") and subtitles:
+                        yield self.mp4_quality, MuxedStream(self.session, s, subtitles, metadata=stream_metadata, disposition=disposition)
+                    else:
+                        yield self.mp4_quality, s
+
+        else:
+            self.logger.error("Could not find experience ID?!")
+
+__plugin__ = FunimationNow
diff --git a/src/streamlink/plugins/pluzz.py b/src/streamlink/plugins/pluzz.py
index 0e8f0a47..63f74160 100644
--- a/src/streamlink/plugins/pluzz.py
+++ b/src/streamlink/plugins/pluzz.py
@@ -3,9 +3,10 @@ import re
 import sys
 import time
 
-from streamlink.plugin import Plugin
+from streamlink.plugin import Plugin, PluginOptions
 from streamlink.plugin.api import http, validate
 from streamlink.stream import HDSStream, HLSStream, HTTPStream
+from streamlink.stream.ffmpegmux import MuxedStream
 
 
 class Pluzz(Plugin):
@@ -56,9 +57,23 @@ class Pluzz(Plugin):
                     }]
                 )
             }]
+        ),
+        'subtitles': validate.any(
+            [],
+            validate.all(
+                [{
+                    'type': validate.text,
+                    'url': validate.url(),
+                    'format': validate.text
+                }]
+            )
         )
     })
 
+    options = PluginOptions({
+        "mux_subtitles": False
+    })
+
     @classmethod
     def can_handle_url(cls, url):
         return Pluzz._url_re.match(url)
@@ -102,6 +117,7 @@ class Pluzz(Plugin):
         drm = False
         expired = False
 
+        streams = []
         for video in videos['videos']:
             video_url = video['url']
 
@@ -147,10 +163,10 @@ class Pluzz(Plugin):
                     pvtoken = stream.request_params['params'].get('pvtoken', '')
                     match = self._hds_pv_data_re.search(pvtoken)
                     if match is None:
-                        yield bitrate, stream
+                        streams.append((bitrate, stream))
             elif '.m3u8' in video_url:
                 for stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
-                    yield stream
+                    streams.append(stream)
             # HBB TV streams are not provided anymore by France Televisions
             elif '.mp4' in video_url and '/hbbtv/' not in video_url:
                 match = self._mp4_bitrate_re.match(video_url)
@@ -160,7 +176,21 @@ class Pluzz(Plugin):
                     # Fallback bitrate (seems all France Televisions MP4 videos
                     # seem have such bitrate)
                     bitrate = '1500k'
-                yield bitrate, HTTPStream(self.session, video_url)
+                streams.append((bitrate, HTTPStream(self.session, video_url)))
+
+        if self.get_option("mux_subtitles") and videos['subtitles'] != []:
+            substreams = {}
+            for subtitle in videos['subtitles']:
+                # TTML subtitles are available but not supported by FFmpeg
+                if subtitle['format'] == 'ttml':
+                    continue
+                substreams[subtitle['type']] = HTTPStream(self.session, subtitle['url'])
+
+            for quality, stream in streams:
+                yield quality, MuxedStream(self.session, stream, subtitles=substreams)
+        else:
+            for stream in streams:
+                yield stream
 
         if offline:
             self.logger.error('Failed to access stream, may be due to offline content')
diff --git a/src/streamlink/plugins/rtve.py b/src/streamlink/plugins/rtve.py
index d9fec303..4357a668 100644
--- a/src/streamlink/plugins/rtve.py
+++ b/src/streamlink/plugins/rtve.py
@@ -58,12 +58,22 @@ class Rtve(Plugin):
     """, re.VERBOSE)
     cdn_schema = validate.Schema(
         validate.transform(parse_xml),
-        validate.xml_findtext(".//url")
+        validate.xml_findall(".//preset"),
+        [
+            validate.union({
+                "quality": validate.all(validate.getattr("attrib"),
+                                        validate.get("type")),
+                "urls": validate.all(
+                    validate.xml_findall(".//url"),
+                    [validate.getattr("text")]
+                )
+            })
+        ]
     )
     subtitles_api = "http://www.rtve.es/api/videos/{id}/subtitulos.json"
     subtitles_schema = validate.Schema({
-        "page": {
-            "items": [{
+            "page": {
+                "items": [{
                     "src": validate.url(),
                     "lang": validate.text
                 }]
@@ -71,6 +81,20 @@ class Rtve(Plugin):
         },
         validate.get("page"),
         validate.get("items"))
+    video_api = "http://www.rtve.es/api/videos/{id}.json"
+    video_schema = validate.Schema({
+            "page": {
+                "items": [{
+                    "qualities": [{
+                        "preset": validate.text,
+                        "height": int
+                    }]
+                }]
+            }
+        },
+        validate.get("page"),
+        validate.get("items"),
+        validate.get(0))
     options = PluginOptions({
         "mux_subtitles": False
     })
@@ -93,13 +117,33 @@ class Rtve(Plugin):
         res = http.get(self.subtitles_api.format(id=content_id))
         return http.json(res, schema=self.subtitles_schema)
 
+    def _get_quality_map(self, content_id):
+        res = http.get(self.video_api.format(id=content_id))
+        data = http.json(res, schema=self.video_schema)
+        qmap = {}
+        for item in data["qualities"]:
+            qname = {"MED": "Media", "HIGH": "Alta"}.get(item["preset"], item["preset"])
+            qmap[qname] = u"{0}p".format(item["height"])
+        return qmap
+
     def _get_streams(self):
+        streams = []
         content_id = self._get_content_id()
         if content_id:
             self.logger.debug("Found content with id: {0}", content_id)
-            hls_url = self.zclient.get_cdn_list(content_id, schema=self.cdn_schema)
-            self.logger.debug("Got stream URL: {0}", hls_url)
-            streams = HLSStream.parse_variant_playlist(self.session, hls_url)
+            stream_data = self.zclient.get_cdn_list(content_id, schema=self.cdn_schema)
+            quality_map = None
+
+            for stream in stream_data:
+                for url in stream["urls"]:
+                    if url.endswith("m3u8"):
+                        streams.extend(HLSStream.parse_variant_playlist(self.session, url).items())
+                    elif url.endswith("mp4"):
+                        if quality_map is None:  # only make the request when it is necessary
+                            quality_map = self._get_quality_map(content_id)
+                        # rename the HTTP sources to match the HLS sources
+                        quality = quality_map.get(stream["quality"], stream["quality"])
+                        streams.append((quality, HTTPStream(self.session, url)))
 
             subtitles = None
             if self.get_option("mux_subtitles"):
@@ -109,10 +153,10 @@ class Rtve(Plugin):
                 for i, subtitle in enumerate(subtitles):
                     substreams[subtitle["lang"]] = HTTPStream(self.session, subtitle["src"])
 
-                for q, s in streams.items():
+                for q, s in streams:
                     yield q, MuxedStream(self.session, s, subtitles=substreams)
             else:
-                for s in streams.items():
+                for s in streams:
                     yield s
 
 
diff --git a/src/streamlink/plugins/showroom.py b/src/streamlink/plugins/showroom.py
new file mode 100644
index 00000000..4618f07b
--- /dev/null
+++ b/src/streamlink/plugins/showroom.py
@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import RTMPStream
+
+_url_re = re.compile(r'''^https?://
+        (?:\w*.)?
+        showroom-live.com/
+        (?:
+            (?P<room_title>[\w-]+$)
+            |
+            room/profile\?room_id=(?P<room_id>\d+)$
+        )
+''', re.VERBOSE)
+
+_room_id_re = re.compile(r'"roomId":(?P<room_id>\d+),')
+_room_id_alt_re = re.compile(r'content="showroom:///room\?room_id=(?P<room_id>\d+)"')
+_room_id_lookup_failure_log = 'Failed to find room_id for {0} using {1} regex'
+
+_api_status_url = 'https://www.showroom-live.com/room/is_live?room_id={room_id}'
+_api_data_url = 'https://www.showroom-live.com/room/get_live_data?room_id={room_id}'
+
+_api_data_schema = validate.Schema(
+    {
+        "streaming_url_list_rtmp": validate.all([
+            {
+                "url": validate.text,
+                "stream_name": validate.text,
+                "id": int,
+                "label": validate.text,
+                "is_default": int
+            }
+        ]),
+        "is_live": int,
+        "room": {
+            "room_url_key": validate.text
+        },
+        "telop": validate.any(None, validate.text)
+    }
+)
+_rtmp_quality_lookup = {
+    "オリジナル画質": "high",
+    "original spec": "high",
+    "低画質": "low",
+    "low spec": "low"
+}
+# changes here must also be updated in test_plugin_showroom
+_quality_weights = {
+    "high": 720,
+    "other": 360,
+    "low": 160
+}
+# pages that definitely aren't rooms
+_info_pages = set((
+    "onlive",
+    "campaign",
+    "timetable",
+    "event",
+    "news",
+    "article",
+    "ranking",
+    "follow",
+    "search",
+    "mypage",
+    "payment",
+    "user",
+    "notice",
+    "s",
+    "organizer_registration",
+    "lottery"
+))
+
+
+class Showroom(Plugin):
+    @staticmethod
+    def _get_stream_info(room_id):
+        res = http.get(_api_data_url.format(room_id=room_id))
+        return http.json(res, schema=_api_data_schema)
+
+    @classmethod
+    def can_handle_url(cls, url):
+        match = _url_re.match(url)
+        if not match or match.group("room_title") in _info_pages:
+            return False
+        return True
+
+    @classmethod
+    def stream_weight(cls, stream):
+        if stream in _quality_weights:
+            return _quality_weights.get(stream), "quality"
+
+        return Plugin.stream_weight(stream)
+
+    def __init__(self, url):
+        Plugin.__init__(self, url)
+        self._room_id = None
+        self._info = None
+        self._title = None
+
+    @property
+    def telop(self):
+        if self._info:
+            return self._info['telop']
+        else:
+            return ""
+
+    @property
+    def room_id(self):
+        if self._room_id is None:
+            self._room_id = self._get_room_id()
+        return self._room_id
+
+    def _get_room_id(self):
+        """
+        Locates unique identifier ("room_id") for the room.
+
+        Returns the room_id as a string, or None if no room_id was found
+        """
+        match_dict = _url_re.match(self.url).groupdict()
+
+        if match_dict['room_id'] is not None:
+            return match_dict['room_id']
+        else:
+            res = http.get(self.url)
+            match = _room_id_re.search(res.text)
+            if not match:
+                title = self.url.rsplit('/', 1)[-1]
+                self.logger.debug(_room_id_lookup_failure_log.format(title, 'primary'))
+                match = _room_id_alt_re.search(res.text)
+                if not match:
+                    self.logger.debug(_room_id_lookup_failure_log.format(title, 'secondary'))
+                    return  # Raise exception?
+            return match.group('room_id')
+
+    def _get_title(self):
+        if self._title is None:
+            if 'profile?room_id=' not in self.url:
+                self._title = self.url.rsplit('/', 1)[-1]
+            else:
+                if self._info is None:
+                    # TODO: avoid this
+                    self._info = self._get_stream_info(self.room_id)
+                self._title = self._info.get('room').get('room_url_key')
+        return self._title
+
+    def _get_rtmp_stream(self, stream_info):
+        rtmp_url = '/'.join((stream_info['url'], stream_info['stream_name']))
+        quality = _rtmp_quality_lookup.get(stream_info['label'], "other")
+
+        params = dict(rtmp=rtmp_url, live=True)
+        return quality, RTMPStream(self.session, params=params)
+
+    def _get_streams(self):
+        self._info = self._get_stream_info(self.room_id)
+        if not self._info or not self._info['is_live']:
+            return
+
+        self.logger.debug("Getting streams for {0}".format(self._get_title()))
+
+        for stream_info in self._info.get("streaming_url_list_rtmp", []):
+            yield self._get_rtmp_stream(stream_info)
+
+
+__plugin__ = Showroom
diff --git a/src/streamlink/plugins/stream.py b/src/streamlink/plugins/stream.py
index 16ff0a62..a87fafa6 100644
--- a/src/streamlink/plugins/stream.py
+++ b/src/streamlink/plugins/stream.py
@@ -25,12 +25,13 @@ SCHEME_REGEX = re.compile(r"^\w+://(.+)")
 
 class StreamURL(Plugin):
     @classmethod
-    def can_handle_url(self, url):
+    def can_handle_url(cls, url):
         parsed = urlparse(url)
 
         return parsed.scheme in PROTOCOL_MAP
 
-    def _parse_params(self, params):
+    @staticmethod
+    def _parse_params(params):
         rval = {}
         matches = re.findall(PARAMS_REGEX, params)
 
@@ -59,7 +60,7 @@ class StreamURL(Plugin):
         if cls != RTMPStream and not SCHEME_REGEX.match(urlnoproto):
             urlnoproto = "http://{0}".format(urlnoproto)
 
-        params = (" ").join(split[1:])
+        params = " ".join(split[1:])
         params = self._parse_params(params)
 
         if cls == RTMPStream:
diff --git a/src/streamlink/plugins/tvplayer.py b/src/streamlink/plugins/tvplayer.py
index e2fd0bcb..83ebcc6f 100644
--- a/src/streamlink/plugins/tvplayer.py
+++ b/src/streamlink/plugins/tvplayer.py
@@ -1,49 +1,83 @@
 #!/usr/bin/env python
 import re
 
-from streamlink.plugin import Plugin
+from streamlink.plugin import Plugin, PluginOptions
 from streamlink.plugin.api import http, validate
+from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 
 class TVPlayer(Plugin):
-    _user_agent = (
-        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) "
-        "Chrome/43.0.2357.65 Safari/537.36")
     API_URL = "http://api.tvplayer.com/api/v2/stream/live"
-    _url_re = re.compile(r"https?://(?:www.)?tvplayer.com/(:?watch/?|watch/(.+)?)")
-    _stream_attrs_ = re.compile(r'var\s+(validate|platform|resourceId)\s+=\s*(.*?);', re.S)
-    _stream_schema = validate.Schema({
+    LOGIN_URL = "https://tvplayer.com/account/login"
+
+    url_re = re.compile(r"https?://(?:www.)?tvplayer.com/(:?watch/?|watch/(.+)?)")
+    stream_attrs_re = re.compile(r'var\s+(validate|platform|resourceId|token)\s+=\s*(.*?);', re.S)
+    login_token_re = re.compile(r'input.*?name="token".*?value="(\w+)"')
+    stream_schema = validate.Schema({
         "tvplayer": validate.Schema({
             "status": u'200 OK',
             "response": validate.Schema({
-                "stream": validate.url(scheme=validate.any("http"))
+                    "stream": validate.url(scheme=validate.any("http")),
+                    validate.optional("drmToken"): validate.any(None, validate.text)
+                })
             })
-        })
+        },
+        validate.get("tvplayer"),
+        validate.get("response"))
+    options = PluginOptions({
+        "email": None,
+        "password": None
     })
 
     @classmethod
     def can_handle_url(cls, url):
-        match = TVPlayer._url_re.match(url)
+        match = TVPlayer.url_re.match(url)
         return match is not None
 
+    def __init__(self, url):
+        super(TVPlayer, self).__init__(url)
+        http.headers.update({"User-Agent": useragents.CHROME})
+
+    def authenticate(self, username, password):
+        res = http.get(self.LOGIN_URL)
+        match = self.login_token_re.search(res.text)
+        token = match and match.group(1)
+        res2 = http.post(self.LOGIN_URL, data=dict(email=username, password=password, token=token),
+                         allow_redirects=False)
+        # there is a 302 redirect on a successful login
+        return res2.status_code == 302
+
     def _get_streams(self):
+        if self.get_option("email") and self.get_option("password"):
+            self.authenticate(self.get_option("email"), self.get_option("password"))
+
         # find the list of channels from the html in the page
         self.url = self.url.replace("https", "http")  # https redirects to http
-        res = http.get(self.url, headers={"User-Agent": TVPlayer._user_agent})
-        stream_attrs = dict((k, v.strip('"')) for k, v in TVPlayer._stream_attrs_.findall(res.text))
+        res = http.get(self.url)
+
+        stream_attrs = dict((k, v.strip('"')) for k, v in self.stream_attrs_re.findall(res.text))
 
         if "resourceId" in stream_attrs and "validate" in stream_attrs and "platform" in stream_attrs:
             # get the stream urls
-            res = http.post(TVPlayer.API_URL, data=dict(id=stream_attrs["resourceId"],
-                                                        validate=stream_attrs["validate"],
-                                                        platform=stream_attrs["platform"]))
+            res = http.post(self.API_URL, data=dict(
+                service=1,
+                id=stream_attrs["resourceId"],
+                validate=stream_attrs["validate"],
+                platform=stream_attrs["platform"],
+                token=stream_attrs.get("token")))
 
-            stream_data = http.json(res, schema=TVPlayer._stream_schema)
+            stream_data = http.json(res, schema=self.stream_schema)
+            print(stream_data)
 
-            return HLSStream.parse_variant_playlist(self.session,
-                                                    stream_data["tvplayer"]["response"]["stream"],
-                                                    headers={'user-agent': TVPlayer._user_agent})
+            if stream_data.get("drmToken"):
+                self.logger.error("This stream is protected by DRM can cannot be played")
+                return
+            else:
+                return HLSStream.parse_variant_playlist(self.session, stream_data["stream"])
+        else:
+            if "need to login" in res.text:
+                self.logger.error("You need to login using --tvplayer-email/--tvplayer-password to view this stream")
 
 
 __plugin__ = TVPlayer
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 6c922347..bfc01657 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -1110,6 +1110,45 @@ plugin.add_argument(
     Automatically mux available subtitles in to the output stream
     """
 )
+plugin.add_argument(
+    "--funimation-language",
+    type=str,
+    choices=["en", "ja", "english", "japanese"],
+    default="english",
+    help="""
+    The audio language to use for Funimation streams; japanese or english
+
+    Default is english
+    """
+)
+plugin.add_argument(
+    "--funimation-mux-subtitles",
+    action="store_true",
+    help="""
+    Enable automatically including available subtitles in to the output stream
+    """
+)
+plugin.add_argument(
+    "--tvplayer-email",
+    metavar="EMAIL",
+    help="""
+    The email address used to register with tvplayer.com.
+    """
+)
+plugin.add_argument(
+    "--tvplayer-password",
+    metavar="PASSWORD",
+    help="""
+    A TVPlayer account password to use with --tvplayer-email.
+    """
+)
+plugin.add_argument(
+    "--pluzz-mux-subtitles",
+    action="store_true",
+    help="""
+    Automatically mux available subtitles in to the output stream
+    """
+)
 
 # Deprecated options
 stream.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index ed01341e..5f3be616 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -861,6 +861,29 @@ def setup_plugin_options():
     if args.rtve_mux_subtitles:
         streamlink.set_plugin_option("rtve", "mux_subtitles", args.rtve_mux_subtitles)
 
+    if args.funimation_mux_subtitles:
+        streamlink.set_plugin_option("funimationnow", "mux_subtitles", True)
+
+    if args.funimation_language:
+        # map en->english, ja->japanese
+        lang = {"en": "english", "ja": "japanese"}.get(args.funimation_language.lower(),
+                                                       args.funimation_language.lower())
+        streamlink.set_plugin_option("funimationnow", "language", lang)
+
+    if args.tvplayer_email:
+        streamlink.set_plugin_option("tvplayer", "email", args.tvplayer_email)
+
+    if args.tvplayer_email and not args.tvplayer_password:
+        tvplayer_password = console.askpass("Enter TVPlayer password: ")
+    else:
+        tvplayer_password = args.tvplayer_password
+
+    if tvplayer_password:
+        streamlink.set_plugin_option("tvplayer", "password", tvplayer_password)
+
+    if args.pluzz_mux_subtitles:
+        streamlink.set_plugin_option("pluzz", "mux_subtitles", args.pluzz_mux_subtitles)
+
     # Deprecated options
     if args.jtv_legacy_names:
         console.logger.warning("The option --jtv/twitch-legacy-names is "


Test patch:
diff --git a/tests/test_plugin_showroom.py b/tests/test_plugin_showroom.py
new file mode 100644
index 00000000..b7b7be37
--- /dev/null
+++ b/tests/test_plugin_showroom.py
@@ -0,0 +1,39 @@
+import unittest
+
+from streamlink.plugins.showroom import Showroom
+
+_should_match = (
+    "https://www.showroom-live.com/48_NISHIMURA_NANAKO",
+    "https://www.showroom-live.com/room/profile?room_id=61734",
+    "http://showroom-live.com/48_YAMAGUCHI_MAHO",
+    "https://www.showroom-live.com/4b9581094890",
+    "https://www.showroom-live.com/157941217780",
+    "https://www.showroom-live.com/madokacom"
+)
+_should_not_match = (
+    "https://www.showroom-live.com/mypage",
+    "https://www.showroom-live.com/ranking",
+    "https://www.showroom-live.com/payment/payment_start",
+    "https://www.showroom-live.com/s/licence",
+    "http://www.youtube.com/",
+    "http://www.dailymotion.com/video/x5cyk8f",
+    "http://www.crunchyroll.com/gintama"
+)
+_stream_weights = {
+    'high': (720, "quality"),
+    'other': (360, "quality"),
+    'low': (160, "quality")
+}
+
+
+class TestPluginShowroom(unittest.TestCase):
+    def test_can_handle_url(self):
+        for url in _should_match:
+            self.assertTrue(Showroom.can_handle_url(url))
+
+        for url in _should_not_match:
+            self.assertFalse(Showroom.can_handle_url(url))
+
+    def test_stream_weight(self):
+        for name, weight in _stream_weights.items():
+            self.assertEqual(Showroom.stream_weight(name), weight)
diff --git a/tests/test_plugin_stream.py b/tests/test_plugin_stream.py
index d55629fe..01a4a97c 100644
--- a/tests/test_plugin_stream.py
+++ b/tests/test_plugin_stream.py
@@ -1,8 +1,7 @@
-import os
 import unittest
 
-from streamlink import Streamlink, PluginError, NoPluginError
-from streamlink.plugins import Plugin
+from streamlink import Streamlink
+from streamlink.plugins.stream import StreamURL
 from streamlink.stream import *
 
 
@@ -56,28 +55,35 @@ class TestPluginStream(unittest.TestCase):
         self.assertEqual(stream.url, url)
         self.assertDictHas(params, stream.args)
 
-    def test_plugin(self):
+    def test_plugin_rtmp(self):
         self._test_rtmp("rtmp://hostname.se/stream",
                         "rtmp://hostname.se/stream", dict())
 
+        self._test_rtmp("rtmp://hostname.se/stream live=1 qarg='a \\'string' noq=test",
+                        "rtmp://hostname.se/stream", dict(live=True, qarg='a \'string', noq="test"))
+
         self._test_rtmp("rtmp://hostname.se/stream live=1 num=47",
                         "rtmp://hostname.se/stream", dict(live=True, num=47))
 
-        self._test_rtmp("rtmp://hostname.se/stream live=1 qarg='a \\'string' noq=test",
-                        "rtmp://hostname.se/stream", dict(live=True, qarg='a \'string', noq="test"))
+        self._test_rtmp("rtmp://hostname.se/stream conn=['B:1','S:authMe','O:1','NN:code:1.23','NS:flag:ok','O:0']",
+                        "rtmp://hostname.se/stream",
+                        dict(conn=['B:1', 'S:authMe', 'O:1', 'NN:code:1.23', 'NS:flag:ok', 'O:0']))
 
+    def test_plugin_hls(self):
         self._test_hls("hls://https://hostname.se/playlist.m3u8",
                        "https://hostname.se/playlist.m3u8")
 
         self._test_hls("hls://hostname.se/playlist.m3u8",
                        "http://hostname.se/playlist.m3u8")
 
+    def test_plugin_akamaihd(self):
         self._test_akamaihd("akamaihd://http://hostname.se/stream",
                             "http://hostname.se/stream")
 
         self._test_akamaihd("akamaihd://hostname.se/stream",
                             "http://hostname.se/stream")
 
+    def test_plugin_http(self):
         self._test_http("httpstream://http://hostname.se/auth.php auth=('test','test2')",
                         "http://hostname.se/auth.php", dict(auth=("test", "test2")))
 
@@ -87,6 +93,19 @@ class TestPluginStream(unittest.TestCase):
         self._test_http("httpstream://https://hostname.se/auth.php verify=False params={'key': 'a value'}",
                         "https://hostname.se/auth.php?key=a+value", dict(verify=False, params=dict(key='a value')))
 
+    def test_parse_params(self):
+        self.assertEqual(
+            dict(verify=False, params=dict(key="a value")),
+            StreamURL._parse_params("""verify=False params={'key': 'a value'}""")
+        )
+        self.assertEqual(
+            dict(verify=False),
+            StreamURL._parse_params("""verify=False""")
+        )
+        self.assertEqual(
+            dict(conn=['B:1', 'S:authMe', 'O:1', 'NN:code:1.23', 'NS:flag:ok', 'O:0']),
+            StreamURL._parse_params(""""conn=['B:1', 'S:authMe', 'O:1', 'NN:code:1.23', 'NS:flag:ok', 'O:0']""")
+        )
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_plugin_tvplayer.py b/tests/test_plugin_tvplayer.py
index 7aadb1fd..13c7c363 100644
--- a/tests/test_plugin_tvplayer.py
+++ b/tests/test_plugin_tvplayer.py
@@ -52,7 +52,7 @@ class TestPluginTVPlayer(unittest.TestCase):
         api_resp.text = json.dumps(api_data)
         mock_http.get.return_value = page_resp
         mock_http.post.return_value = api_resp
-        mock_http.json.return_value = api_data
+        mock_http.json.return_value = api_data["tvplayer"]["response"]
         hlsstream.parse_variant_playlist.return_value = {"test": HLSStream(self.session, "http://test.se/stream1")}
 
         plugin = TVPlayer("http://tvplayer.com/watch/dave")
@@ -62,13 +62,15 @@ class TestPluginTVPlayer(unittest.TestCase):
         self.assertTrue("test" in streams)
 
         # test the url is used correctly
-        mock_http.get.assert_called_with("http://tvplayer.com/watch/dave", headers=ANY)
+        mock_http.get.assert_called_with("http://tvplayer.com/watch/dave")
         # test that the correct API call is made
-        mock_http.post.assert_called_with("http://api.tvplayer.com/api/v2/stream/live", data=dict(id=u"1234",
+        mock_http.post.assert_called_with("http://api.tvplayer.com/api/v2/stream/live", data=dict(service=1,
+                                                                                                  id=u"1234",
                                                                                                   validate=u"foo",
-                                                                                                  platform=u"test"))
+                                                                                                  platform=u"test",
+                                                                                                  token=None))
         # test that the correct URL is used for the HLSStream
-        hlsstream.parse_variant_playlist.assert_called_with(ANY, "http://test.se/stream1", headers=ANY)
+        hlsstream.parse_variant_playlist.assert_called_with(ANY, "http://test.se/stream1")
 
     @patch('streamlink.plugins.tvplayer.http')
     def test_get_invalid_page(self, mock_http):
@@ -86,4 +88,5 @@ class TestPluginTVPlayer(unittest.TestCase):
         self.assertEqual({}, streams)
 
         # test the url is used correctly
-        mock_http.get.assert_called_with("http://tvplayer.com/watch/dave", headers=ANY)
+
+        mock_http.get.assert_called_with("http://tvplayer.com/watch/dave")