Repository: streamlink/streamlink
Pull request #609 (2017-02-20 11:34:55)

plugins.daisuki: add support for VOD streams from daisuki.net
Main plugin courtesy of @intact: https://gist.github.com/intact/3d6af5f361c1a44fd878 👍 

Adds a new option `--daisuki-mux-subtitles` that will automatically convert the xml subtitles to srt and mux them into the output stream which requires `ffmpeg`. (Which I think is pretty cool.)


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 048f134f..dfedf2a2 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -52,6 +52,7 @@ connectcast         connectcast.tv       Yes   Yes
 crunchyroll         crunchyroll.com      --    Yes
 cybergame           cybergame.tv         Yes   Yes
 dailymotion         dailymotion.com      Yes   Yes
+daisuki             daisuki.net          No    Yes
 dingittv            dingit.tv            Yes   Yes
 disney_de           - video.disney.de    Yes   Yes   Streams may be geo-restricted to Germany.
                     - disneychannel.de
@@ -118,6 +119,8 @@ periscope           periscope.tv         Yes   Yes   Replay/VOD is supported.
 picarto             picarto.tv           Yes   --
 playtv              playtv.fr            Yes   --    Streams may be geo-restricted to France.
 pluzz               pluzz.francetv.fr    Yes   Yes   Streams may be geo-restricted to France, Andorra and Monaco.
+                    ludo.fr
+                    zouzous.fr
 powerapp            powerapp.com.tr      Yes   No
 raiplay             raiplay.it           Yes   No    Most streams are geo-restricted to Italy.
 rtlxl               rtlxl.nl             No    Yes   Streams may be geo-restricted to The Netherlands. Livestreams not supported.
diff --git a/src/streamlink/plugins/daisuki.py b/src/streamlink/plugins/daisuki.py
new file mode 100644
index 00000000..e6ca37f1
--- /dev/null
+++ b/src/streamlink/plugins/daisuki.py
@@ -0,0 +1,246 @@
+import base64
+import json
+import random
+import re
+import tempfile
+import time
+import warnings
+
+from Crypto.Cipher import AES, PKCS1_v1_5
+from Crypto.PublicKey import RSA
+from Crypto.Util import number
+
+from streamlink.compat import urljoin, urlparse
+from streamlink.exceptions import PluginError
+from streamlink.plugin import Plugin, PluginOptions
+from streamlink.plugin.api import http, validate
+from streamlink.plugin.api.utils import parse_json
+from streamlink.stream import HLSStream
+from streamlink.stream.ffmpegmux import MuxedStream, FFMPEGMuxer
+from streamlink.stream.file import FileStream
+from streamlink.utils.l10n import Language
+
+HDCORE_VERSION = "3.2.0"
+
+_url_re = re.compile(r"https?://www.daisuki.net/[^/]+/[^/]+/anime/watch\..+")
+_flashvars_re = re.compile(r"var\s+flashvars\s*=\s*\{([^}]*?)};", re.DOTALL)
+_flashvar_re = re.compile(r"""(['"])(.*?)\1\s*:\s*(['"])(.*?)\3""")
+_clientlibs_re = re.compile(r"""<script.*?src=(['"])(.*?/clientlibs_anime_watch.*?\.js)\1""")
+
+_schema = validate.Schema(
+    validate.union({
+        "flashvars": validate.all(
+            validate.transform(_flashvars_re.search),
+            validate.get(1),
+            validate.transform(_flashvar_re.findall),
+            validate.map(lambda v: (v[1], v[3])),
+            validate.transform(dict),
+            {
+                "s": validate.text,
+                "country": validate.text,
+                "init": validate.text,
+                validate.optional("ss_id"): validate.text,
+                validate.optional("mv_id"): validate.text,
+                validate.optional("device_cd"): validate.text,
+                validate.optional("ss1_prm"): validate.text,
+                validate.optional("ss2_prm"): validate.text,
+                validate.optional("ss3_prm"): validate.text
+            }
+        ),
+        "clientlibs": validate.all(
+            validate.transform(_clientlibs_re.search),
+            validate.get(2),
+            validate.text
+        )
+    })
+)
+
+_language_schema = validate.Schema(
+    validate.xml_findtext("./country_code")
+)
+
+_xml_to_srt_schema = validate.Schema(
+    validate.xml_findall(".//body/div"),
+    [
+        validate.union([validate.all(
+                validate.getattr("attrib"),
+                validate.get("{http://www.w3.org/XML/1998/namespace}lang")
+            ),
+            validate.all(
+                validate.xml_findall("./p"),
+                validate.transform(lambda x: list(enumerate(x, 1))),
+                [
+                    validate.all(
+                        validate.union({
+                            "i": validate.get(0),
+                            "begin": validate.all(
+                                validate.get(1),
+                                validate.getattr("attrib"),
+                                validate.get("begin"),
+                                validate.transform(lambda s: s.replace(".", ","))
+                            ),
+                            "end": validate.all(
+                                validate.get(1),
+                                validate.getattr("attrib"),
+                                validate.get("end"),
+                                validate.transform(lambda s: s.replace(".", ","))
+                            ),
+                            "text": validate.all(
+                                validate.get(1),
+                                validate.getattr("text"),
+                                validate.transform(lambda s: s.strip()),
+                                validate.transform(lambda s: s.replace("<br />", "\n"))
+                            )
+                        }),
+                        validate.transform(
+                            lambda d: "{i}\n{begin} --> {end}\n{text}\n".format(**d)
+                        )
+                    )
+                ],
+                validate.transform(lambda s: '\n'.join(s))
+            )
+        ])
+    ]
+)
+
+_init_schema = validate.Schema(
+    {
+        "rtn": validate.all(
+            validate.text
+        )
+    },
+    validate.get("rtn")
+)
+
+
+def aes_encrypt(key, plaintext):
+    plaintext = plaintext.encode("utf-8")
+    aes = AES.new(key, AES.MODE_CBC, number.long_to_bytes(0, AES.block_size))
+    if len(plaintext) % AES.block_size != 0:
+        plaintext += b"\0" * (AES.block_size - len(plaintext) % AES.block_size)
+    return base64.b64encode(aes.encrypt(plaintext))
+
+
+def aes_decrypt(key, ciphertext):
+    aes = AES.new(key, AES.MODE_CBC, number.long_to_bytes(0, AES.block_size))
+    plaintext = aes.decrypt(base64.b64decode(ciphertext))
+    plaintext = plaintext.strip(b"\0")
+    return plaintext.decode("utf-8")
+
+
+def rsa_encrypt(key, plaintext):
+    pubkey = RSA.importKey(key)
+    cipher = PKCS1_v1_5.new(pubkey)
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        return base64.b64encode(cipher.encrypt(plaintext))
+
+
+def get_public_key(cache, url):
+    headers = {}
+
+    cached = cache.get("clientlibs")
+    if cached and cached["url"] == url:
+        headers["If-Modified-Since"] = cached["modified"]
+
+    script = http.get(url, headers=headers)
+    if cached and script.status_code == 304:
+        return cached["pubkey"]
+
+    modified = script.headers.get("Last-Modified", "")
+
+    match = re.search(r"""\"(-----BEGIN PUBLIC KEY-----[^"]*?-----END PUBLIC KEY-----)\"""", script.text, re.DOTALL)
+    if match is None:
+        return None
+
+    pubkey = match.group(1).replace("\\n", "\n")
+
+    cache.set("clientlibs", dict(url=url, modified=modified, pubkey=pubkey))
+
+    return pubkey
+
+
+class Daisuki(Plugin):
+    options = PluginOptions({
+        "mux_subtitles": False
+    })
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return _url_re.match(url)
+
+    def _get_streams(self):
+        page = http.get(self.url, schema=_schema)
+        if not page:
+            return
+
+        pubkey_pem = get_public_key(self.cache, urljoin(self.url, page["clientlibs"]))
+        if not pubkey_pem:
+            raise PluginError("Unable to get public key")
+
+        flashvars = page["flashvars"]
+
+        params = {
+            "cashPath": int(time.time() * 1000)
+        }
+        res = http.get(urljoin(self.url, flashvars["country"]), params=params)
+        if not res:
+            return
+        language = http.xml(res, schema=_language_schema)
+
+        api_params = {}
+        for key in ("ss_id", "mv_id", "device_cd", "ss1_prm", "ss2_prm", "ss3_prm"):
+            if flashvars.get(key, ""):
+                api_params[key] = flashvars[key]
+
+        aeskey = number.long_to_bytes(random.getrandbits(8 * 32), 32)
+
+        params = {
+            "s": flashvars["s"],
+            "c": language,
+            "e": self.url,
+            "d": aes_encrypt(aeskey, json.dumps(api_params)),
+            "a": rsa_encrypt(pubkey_pem, aeskey)
+        }
+        res = http.get(urljoin(self.url, flashvars["init"]), params=params)
+        if not res:
+            return
+        rtn = http.json(res, schema=_init_schema)
+        if not rtn:
+            return
+
+        init_data = parse_json(aes_decrypt(aeskey, rtn))
+
+        parsed = urlparse(init_data["play_url"])
+        if parsed.scheme != "https" or not parsed.path.startswith("/i/") or not parsed.path.endswith("/master.m3u8"):
+            return
+        hlsstream_url = init_data["play_url"]
+
+        streams = HLSStream.parse_variant_playlist(self.session, hlsstream_url)
+
+        if "caption_url" in init_data:
+            if self.get_option("mux_subtitles") and FFMPEGMuxer.is_usable(self.session):
+                res = http.get(init_data["caption_url"])
+                srt = http.xml(res, ignore_ns=True, schema=_xml_to_srt_schema)
+                subfiles = []
+                metadata = {}
+                for i, lang, srt in ((i, s[0], s[1]) for i, s in enumerate(srt)):
+                    subfile = tempfile.TemporaryFile()
+                    subfile.write(srt.encode("utf8"))
+                    subfile.seek(0)
+                    subfiles.append(FileStream(self.session, fileobj=subfile))
+                    metadata["s:s:{0}".format(i)] = ["language={0}".format(lang)]
+
+                for n, s in streams.items():
+                    yield n, MuxedStream(self.session, s, *subfiles,
+                                         maps=list(range(0, len(metadata)+1)),
+                                         metadata=metadata)
+                return
+            else:
+                self.logger.info("Subtitles: {0}".format(init_data["caption_url"]))
+
+        for s in streams.items():
+            yield s
+
+
+__plugin__ = Daisuki
diff --git a/src/streamlink/plugins/pluzz.py b/src/streamlink/plugins/pluzz.py
index c4ef67c5..0e8f0a47 100644
--- a/src/streamlink/plugins/pluzz.py
+++ b/src/streamlink/plugins/pluzz.py
@@ -5,19 +5,21 @@ import time
 
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
-from streamlink.stream import HDSStream, HLSStream
+from streamlink.stream import HDSStream, HLSStream, HTTPStream
 
 
 class Pluzz(Plugin):
     GEO_URL = 'http://geo.francetv.fr/ws/edgescape.json'
-    API_URL = 'http://sivideo.webservices.francetelevisions.fr/tools/getInfosOeuvre/v2/?idDiffusion={0}&catalogue=Pluzz'
+    API_URL = 'http://sivideo.webservices.francetelevisions.fr/tools/getInfosOeuvre/v2/?idDiffusion={0}&catalogue={1}'
     TOKEN_URL = 'http://hdfauthftv-a.akamaihd.net/esi/TA?url={0}'
 
-    _url_re = re.compile(r'http://pluzz\.francetv\.fr/(videos/.+\.html|[\w-]+)')
-    _video_id_re = re.compile(r'id="current_video" href="http://.+?\.(?:francetv|francetelevisions)\.fr/(?:video/|\?id-video=)(?P<video_id>.+?)"')
-    _player_re = re.compile(r'<script type="text/javascript" src="(?P<player>//staticftv-a\.akamaihd\.net/player/jquery\.player.+?-[0-9a-f]+?\.js)"></script>')
+    _url_re = re.compile(r'http://(pluzz\.francetv\.fr/(videos/.+\.html|[\w-]+)|www\.(ludo|zouzous)\.fr/heros/[\w-]+)')
+    _pluzz_video_id_re = re.compile(r'id="current_video" href="http://.+?\.(?:francetv|francetelevisions)\.fr/(?:video/|\?id-video=)(?P<video_id>.+?)"')
+    _other_video_id_re = re.compile(r'playlist: \[{.*?,"identity":"(?P<video_id>.+?)@(?P<catalogue>Ludo|Zouzous)"')
+    _player_re = re.compile(r'src="(?P<player>//staticftv-a\.akamaihd\.net/player/jquery\.player.+?-[0-9a-f]+?\.js)"></script>')
     _swf_re = re.compile(r'getUrl\("(?P<swf>/bower_components/player_flash/dist/FranceTVNVPVFlashPlayer\.akamai.+?\.swf)"\)')
     _hds_pv_data_re = re.compile(r"~data=.+?!")
+    _mp4_bitrate_re = re.compile(r'.*-(?P<bitrate>[0-9]+k)\.mp4')
 
     _geo_schema = validate.Schema({
         'reponse': {
@@ -69,10 +71,17 @@ class Pluzz(Plugin):
 
         # Retrieve URL page and search for video ID
         res = http.get(self.url)
-        match = self._video_id_re.search(res.text)
+        if 'pluzz.francetv.fr' in self.url:
+            video_re = self._pluzz_video_id_re
+        else:
+            video_re = self._other_video_id_re
+        match = video_re.search(res.text)
         if match is None:
             return
+        catalogue = 'Pluzz'
         video_id = match.group('video_id')
+        if 'catalogue' in match.groupdict():
+            catalogue = match.group('catalogue')
 
         # Retrieve SWF player URL
         match = self._player_re.search(res.text)
@@ -84,7 +93,7 @@ class Pluzz(Plugin):
             if match is not None:
                 swf_url = os.path.dirname(player_url) + match.group('swf')
 
-        res = http.get(self.API_URL.format(video_id))
+        res = http.get(self.API_URL.format(video_id, catalogue))
         videos = http.json(res, schema=self._api_schema)
         now = time.time()
 
@@ -92,6 +101,7 @@ class Pluzz(Plugin):
         geolocked = False
         drm = False
         expired = False
+
         for video in videos['videos']:
             video_url = video['url']
 
@@ -124,8 +134,9 @@ class Pluzz(Plugin):
             if '.mpd' in video_url:
                 continue
 
-            res = http.get(self.TOKEN_URL.format(video_url))
-            video_url = res.text
+            if catalogue == 'Pluzz' or '.f4m' in video_url:
+                res = http.get(self.TOKEN_URL.format(video_url))
+                video_url = res.text
 
             if '.f4m' in video_url and swf_url is not None:
                 for bitrate, stream in HDSStream.parse_manifest(self.session, video_url, pvswf=swf_url).items():
@@ -140,6 +151,16 @@ class Pluzz(Plugin):
             elif '.m3u8' in video_url:
                 for stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
                     yield 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)
+                if match is not None:
+                    bitrate = match.group('bitrate')
+                else:
+                    # Fallback bitrate (seems all France Televisions MP4 videos
+                    # seem have such bitrate)
+                    bitrate = '1500k'
+                yield bitrate, HTTPStream(self.session, video_url)
 
         if offline:
             self.logger.error('Failed to access stream, may be due to offline content')
diff --git a/src/streamlink/plugins/zhanqi.py b/src/streamlink/plugins/zhanqi.py
index 1a9c83fe..80829518 100644
--- a/src/streamlink/plugins/zhanqi.py
+++ b/src/streamlink/plugins/zhanqi.py
@@ -2,47 +2,18 @@ import re
 
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
-from streamlink.plugin.api.utils import parse_json
-from streamlink.stream import (
-    HTTPStream, HLSStream
-)
+from streamlink.stream import HTTPStream, HLSStream
 
-API_URL = "http://www.zhanqi.tv/api/static/live.roomid/{roomID[id]}.json"
+API_URL = "https://www.zhanqi.tv/api/static/v2.1/room/domain/{0}.json"
 
 STATUS_ONLINE = 4
 STATUS_OFFLINE = 0
 
-#hls source is not stable, lower priority
-STREAM_WEIGHTS = {
-    "live": 1080
-}
-
 _url_re = re.compile(r"""
     http(s)?://(www\.)?zhanqi.tv
     /(?P<channel>[^/]+)
 """, re.VERBOSE)
 
-_json_re = re.compile(r"window\.oPageConfig\.oRoom\s*=\s*({.+?});")
-
-_roomID_schema = validate.Schema(
-    validate.all(
-        validate.transform(_json_re.search),
-        validate.any(
-            None,
-            validate.all(
-                validate.get(1),
-                validate.transform(parse_json),
-                {
-                    "id": validate.all(
-                        validate.text,
-                        validate.transform(int)
-                    )
-                }
-            )
-        )
-    )
-)
-
 _room_schema = validate.Schema(
     {
         "data": validate.any(None, {
@@ -62,37 +33,27 @@ class Zhanqitv(Plugin):
     def can_handle_url(self, url):
         return _url_re.match(url)
 
-    @classmethod
-    def stream_weight(cls, stream):
-        if stream in STREAM_WEIGHTS:
-            return STREAM_WEIGHTS[stream], "zhanqitv"
-        return Plugin.stream_weight(stream)
-
     def _get_streams(self):
         match = _url_re.match(self.url)
         channel = match.group("channel")
 
-        roomID = http.get(self.url, schema=_roomID_schema)
-
-        res = http.get(API_URL.format(roomID=roomID))
+        res = http.get(API_URL.format(channel))
         room = http.json(res, schema=_room_schema)
         if not room:
             self.logger.info("Not a valid room url.")
             return
 
         if room["status"] != STATUS_ONLINE:
-            self.logger.info("Stream current unavailable.")
+            self.logger.info("Stream currently unavailable.")
             return
 
-        hls_url = "http://dlhls.cdn.zhanqi.tv/zqlive/{room[videoId]}_1024/index.m3u8?Dnion_vsnae={room[videoId]}".format(room=room)
-        hls_stream = HLSStream(self.session, hls_url)
-        if hls_stream:
-            yield "hls", hls_stream
+        url = "http://wshdl.load.cdn.zhanqi.tv/zqlive/{room[videoId]}.flv?get_url=".format(room=room)
+        stream = HTTPStream(self.session, url)
+        yield "live", stream
 
-        http_url = "http://wshdl.load.cdn.zhanqi.tv/zqlive/{room[videoId]}.flv?get_url=".format(room=room)
-        http_stream = HTTPStream(self.session, http_url)
-        if http_stream:
-            yield "http", http_stream
+        url = "http://dlhls.cdn.zhanqi.tv/zqlive/{room[videoId]}_1024/index.m3u8?Dnion_vsnae={room[videoId]}".format(room=room)
+        stream = HLSStream(self.session, url)
+        yield "live", stream
 
 
 __plugin__ = Zhanqitv
diff --git a/src/streamlink/stream/ffmpegmux.py b/src/streamlink/stream/ffmpegmux.py
index c1c09c6f..a4ae5aa1 100644
--- a/src/streamlink/stream/ffmpegmux.py
+++ b/src/streamlink/stream/ffmpegmux.py
@@ -12,6 +12,7 @@ from streamlink.stream.stream import StreamIO
 from streamlink.utils import NamedPipe
 from streamlink.compat import devnull, which
 
+
 class MuxedStream(Stream):
     __shortname__ = "muxed-stream"
 
@@ -23,6 +24,7 @@ class MuxedStream(Stream):
     def open(self):
         fds = []
         for substream in self.substreams:
+            self.logger.debug("Opening {0} substream".format(substream.shortname()))
             fds.append(substream and substream.open())
         return FFMPEGMuxer(self.session, *fds, **self.options).open()
 
@@ -73,6 +75,7 @@ class FFMPEGMuxer(StreamIO):
         videocodec = session.options.get("ffmpeg-video-transcode") or options.pop("vcodec", "copy")
         audiocodec = session.options.get("ffmpeg-audio-transcode") or options.pop("acodec", "copy")
         metadata = options.pop("metadata", {})
+        maps = options.pop("maps", [])
 
         self._cmd = [self.command(session), '-nostats', '-y']
         for np in self.pipes:
@@ -81,6 +84,9 @@ class FFMPEGMuxer(StreamIO):
         self._cmd.extend(['-c:v', videocodec])
         self._cmd.extend(['-c:a', audiocodec])
 
+        for m in maps:
+            self._cmd.extend(["-map", str(m)])
+
         for stream, data in metadata.items():
             for datum in data:
                 self._cmd.extend(["-metadata:{0}".format(stream), datum])
diff --git a/src/streamlink/stream/file.py b/src/streamlink/stream/file.py
new file mode 100644
index 00000000..89176661
--- /dev/null
+++ b/src/streamlink/stream/file.py
@@ -0,0 +1,17 @@
+"""
+Stream wrapper around a file
+"""
+from streamlink.stream import Stream
+
+
+class FileStream(Stream):
+    __shortname__ = "file"
+    def __init__(self, session, path=None, fileobj=None):
+        super(FileStream, self).__init__(session)
+        self.path = path
+        self.fileobj = fileobj
+        if not self.path and not self.fileobj:
+            raise ValueError("path or fileobj must be set")
+
+    def open(self):
+        return self.fileobj or open(self.path)
diff --git a/src/streamlink/stream/rtmpdump.py b/src/streamlink/stream/rtmpdump.py
index 2b24a4a6..8518c3a3 100644
--- a/src/streamlink/stream/rtmpdump.py
+++ b/src/streamlink/stream/rtmpdump.py
@@ -26,7 +26,6 @@ class RTMPStream(StreamProcess):
         self.cmd = self.session.options.get("rtmp-rtmpdump")
         self.timeout = self.session.options.get("rtmp-timeout")
         self.redirect = redirect
-        self.logger = session.logger.new_module("stream.rtmp")
 
         # set rtmpdump logging level
         if self.session.options.get("subprocess-errorlog-path") or \
diff --git a/src/streamlink/stream/stream.py b/src/streamlink/stream/stream.py
index 60acebe4..53ff9362 100644
--- a/src/streamlink/stream/stream.py
+++ b/src/streamlink/stream/stream.py
@@ -12,6 +12,7 @@ class Stream(object):
 
     def __init__(self, session):
         self.session = session
+        self.logger = self.session.logger.new_module("stream.".format(self.shortname()))
 
     def __repr__(self):
         return "<Stream()>"
diff --git a/src/streamlink/stream/streamprocess.py b/src/streamlink/stream/streamprocess.py
index 99bdb2f3..625ad3c8 100644
--- a/src/streamlink/stream/streamprocess.py
+++ b/src/streamlink/stream/streamprocess.py
@@ -40,8 +40,6 @@ class StreamProcess(Stream):
         self.parameters = params or {}
         self.arguments = args or []
         self.timeout = timeout
-        self.logger = session.logger.new_module("stream.process")
-
         self.errorlog = self.session.options.get("subprocess-errorlog")
         self.errorlog_path = self.session.options.get("subprocess-errorlog-path")
 
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 2e2d0708..259c45cb 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -1096,6 +1096,13 @@ plugin.add_argument(
     Defaults is 1
     """
 )
+plugin.add_argument(
+    "--daisuki-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 3a1d5204..3e2bbcf3 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -855,6 +855,9 @@ def setup_plugin_options():
     if args.schoolism_part:
         streamlink.set_plugin_option("schoolism", "part", args.schoolism_part)
 
+    if args.daisuki_mux_subtitles:
+        streamlink.set_plugin_option("daisuki", "mux_subtitles", args.daisuki_mux_subtitles)
+
     # Deprecated options
     if args.jtv_legacy_names:
         console.logger.warning("The option --jtv/twitch-legacy-names is "
diff --git a/src/streamlink_cli/utils/http_server.py b/src/streamlink_cli/utils/http_server.py
index 2e88db56..42dedc94 100644
--- a/src/streamlink_cli/utils/http_server.py
+++ b/src/streamlink_cli/utils/http_server.py
@@ -112,5 +112,8 @@ class HTTPServer(object):
             self.conn.close()
 
         if not client_only:
-            self.socket.shutdown(2)
+            try:
+                self.socket.shutdown(2)
+            except OSError:
+                pass
             self.socket.close()


Test patch:
diff --git a/tests/test_plugin_pluzz.py b/tests/test_plugin_pluzz.py
index f536e175..70680220 100644
--- a/tests/test_plugin_pluzz.py
+++ b/tests/test_plugin_pluzz.py
@@ -15,8 +15,15 @@ class TestPluginPluzz(unittest.TestCase):
         self.assertTrue(Pluzz.can_handle_url("http://pluzz.francetv.fr/franceinfo"))
         self.assertTrue(Pluzz.can_handle_url("http://pluzz.francetv.fr/videos/jt20h.html"))
         self.assertTrue(Pluzz.can_handle_url("http://pluzz.francetv.fr/videos/jt_1213_franche_comte.html"))
+        self.assertTrue(Pluzz.can_handle_url("http://www.ludo.fr/heros/the-batman"))
+        self.assertTrue(Pluzz.can_handle_url("http://www.ludo.fr/heros/il-etait-une-fois-la-vie"))
+        self.assertTrue(Pluzz.can_handle_url("http://www.zouzous.fr/heros/oui-oui"))
+        self.assertTrue(Pluzz.can_handle_url("http://www.zouzous.fr/heros/marsupilami-1"))
 
         # shouldn't match
         self.assertFalse(Pluzz.can_handle_url("http://pluzz.francetv.fr/"))
+        self.assertFalse(Pluzz.can_handle_url("http://www.ludo.fr/"))
+        self.assertFalse(Pluzz.can_handle_url("http://www.ludo.fr/jeux"))
+        self.assertFalse(Pluzz.can_handle_url("http://www.zouzous.fr/"))
         self.assertFalse(Pluzz.can_handle_url("http://www.tvcatchup.com/"))
         self.assertFalse(Pluzz.can_handle_url("http://www.youtube.com/"))
diff --git a/tests/test_plugin_tvplayer.py b/tests/test_plugin_tvplayer.py
index af841aaa..7aadb1fd 100644
--- a/tests/test_plugin_tvplayer.py
+++ b/tests/test_plugin_tvplayer.py
@@ -1,6 +1,8 @@
 import json
 import unittest
 
+from streamlink import Streamlink
+
 try:
     from unittest.mock import patch, Mock, ANY
 except ImportError:
@@ -10,6 +12,9 @@ from streamlink.stream import HLSStream
 
 
 class TestPluginTVPlayer(unittest.TestCase):
+    def setUp(self):
+        self.session = Streamlink()
+
     def test_can_handle_url(self):
         # should match
         self.assertTrue(TVPlayer.can_handle_url("http://tvplayer.com/watch/"))
@@ -48,7 +53,7 @@ class TestPluginTVPlayer(unittest.TestCase):
         mock_http.get.return_value = page_resp
         mock_http.post.return_value = api_resp
         mock_http.json.return_value = api_data
-        hlsstream.parse_variant_playlist.return_value = {"test": HLSStream(None, "http://test.se/stream1")}
+        hlsstream.parse_variant_playlist.return_value = {"test": HLSStream(self.session, "http://test.se/stream1")}
 
         plugin = TVPlayer("http://tvplayer.com/watch/dave")