Repository: streamlink/streamlink
Pull request #502 (2017-01-28 15:40:55)

Add plugin for 17app.co
#235


Patch:
diff --git a/.travis.yml b/.travis.yml
index 50187232..5424186c 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,7 +2,6 @@ language: python
 
 matrix:
   include:
-  - python: '2.6'
   - python: '2.7'
   - python: '3.3'
   - python: '3.4'
@@ -11,11 +10,13 @@ matrix:
   - python: '3.6'
   - python: '3.7-dev'
   allow_failures:
+  - python: '2.6'
   - python: '3.7-dev'
 
 before_install:
   - pip install --disable-pip-version-check --upgrade pip
   - pip install -r dev-requirements.txt
+  - pip install pycountry 
   - pip install -r docs-requirements.txt
 
 install:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 72676dc8..0256e800 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,91 @@
+streamlink 0.3.2 (2017-02-10)
+-----------------------------
+0.3.2 release of Streamlink!
+
+A minor bug release of 0.3.2 to fix a few issues with stream providers. 
+
+Thanks to all whom have contributed to this (tiny) release!
+
+::
+
+    Charlie Drage <charlie@charliedrage.com> (3):
+          Update release script again to include sdist
+          Fix underlining issue
+          Fix the CHANGELOG.rst
+    
+    Sven <sven@androd.se> (1):
+          Adding Huomao plugin with possibility for different stream qualities.
+    
+    beardypig <beardypig@users.noreply.github.com> (7):
+          Ensure retries with HLS Streams (#522)
+          utils.l10n: add Country/Language classes, use pycountry is the iso modules are not available
+          plugins.crunchyroll: added option to set the session id to a specific value
+          CI: add pycountry for testing
+          plugins.openrectv: add source quality for openrectv
+          utils.l10n: default to en_US when an invalid locale is set
+          stream.hls: pick a better default stream language
+    
+    intact <intact.devel@gmail.com> (1):
+          Add Rtvs.sk Plugin
+    
+
+streamlink 0.3.1 (2017-02-03)
+-----------------------------
+0.3.1 release of Streamlink
+
+A *minor* release, we update our source code upload to *not* include the ffmpeg.exe binary as well as update a multitude of plugins.
+
+Thanks again for all the contributions as well as updates!
+
+::
+
+    Charlie Drage <charlie@charliedrage.com> (1):
+          Updating the release script.
+    
+    Forrest <gravyboat@users.noreply.github.com> (1):
+          Update license and debian copyright (#515)
+    
+    Forrest Alvarez <gravyboat@users.noreply.github.com> (1):
+          Update license and debian copyright
+    
+    John Smith <v2.0@protonmail.com> (1):
+          plugins.bongacams: a few small changes (#429)
+    
+    Mohamed El Morabity <melmorabity@fedoraproject.org> (1):
+          Check whether videos are DRM-protected Add log messages when no stream is available
+    
+    Mohamed El Morabity <melmorabity@users.noreply.github.com> (1):
+          Add support for replay.gulli.fr (#468)
+    
+    beardypig <beardypig@users.noreply.github.com> (20):
+          plugins.schoolism: add support for schoolism.com
+          stream.hls_playlist: invalid durations in EXTINF lines are ignored
+          plugins.livecoding: update to support the new domain: liveedu.tv
+          plugins.srgssr: fix playlist reload auth issue
+          Play twitch VOD stream from the beginning even if is still being recorded
+          cli: wait for process to exit, not exit with non-0 error code
+          Fix bug in customized Windows install
+          add a general locale setting which can be used by plugins
+          stream.hls: support external audio tracks
+          plugins.turkuvaz: add referer to the secure token request
+          localization: search for language codes in part2t+part2b+part3
+          localization: invalid language/country codes are always inequivalent
+          stream.hls: only support external audio tracks if ffmpeg is available
+          installer: include the missing pkg_resources package
+          Rewritten StreamProcess class (#441)
+          plugins.dogus: fix for ntv streams not being found
+          plugins.dogus: add support for eurostartv live stream
+          plugins.twitch: update public API calls to use v5 API (#484)
+          plugins.filmon: support for new site layout (#508)
+          Support for Ceskatelevize streams (#520)
+    
+    fozzy <fozzysec@gmail.com> (1):
+          Add support for Huya.com in issue #425 (#465)
+    
+    steven7851 <steven7851@msn.com> (1):
+          plugins.douyutv: fix room id regex (#514)
+    
+
 streamlink 0.3.0 (2017-01-24)
 -------------------------------
 
diff --git a/LICENSE b/LICENSE
index 5924cf28..e298ae80 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,5 @@
 Copyright (c) 2011-2016, Christopher Rosell
+Copyright (c) 2016-2017, Streamlink Team
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without
diff --git a/appveyor.yml b/appveyor.yml
index e0ef7c40..ed6353e1 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -44,7 +44,8 @@ install:
 
   # install dev requirements, for testing, etc.
   - "pip install -r dev-requirements.txt"
-  - "pip install ."
+  - "pip install pycountry" 
+  - "build.cmd %PYTHON%\\python.exe -m pip install ."
 
 build: off
 
@@ -59,4 +60,4 @@ after_test:
   - if exist coverage.xml (cp coverage.xml dist\) else (echo no coverage.xml)
 
 artifacts:
-  - path: dist\*
\ No newline at end of file
+  - path: dist\*
diff --git a/debian/copyright b/debian/copyright
index b6d03b8f..e6a628bf 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -5,7 +5,7 @@ Upstream-Name: streamlink
 
 Files: *
 Copyright: 2011-2016 Christopher Rosell <chrippa@tanuki.se>
-           2016 Streamlink Team
+           2016-2017 Streamlink Team
 License: BSD-2-clause
 
 Files: src/streamlink/packages/pbs.py
diff --git a/docs/install.rst b/docs/install.rst
index e6260602..4a36b41e 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -146,12 +146,12 @@ To install Streamlink from source you will need these dependencies.
 ==================================== ===========================================
 Name                                 Notes
 ==================================== ===========================================
-`Python`_                            At least version **2.6** or **3.3**.
+`Python`_                            At least version **2.7** or **3.3**.
 `python-setuptools`_
 
 **Automatically installed by the setup script**
 --------------------------------------------------------------------------------
-`python-argparse`_                   Only needed on Python **2.6**.
+`python-argparse`_                   Only needed on Python versions older than **2.7**.
 `python-futures`_                    Only needed on Python **2.x**.
 `python-requests`_                   At least version **1.0**.
 `python-singledispatch`_             Only needed on Python versions older than **3.4**.
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 51af2a96..fa5048aa 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -18,6 +18,7 @@ afreecatv           afreeca.tv           Yes   No
 aftonbladet         aftonbladet.se       Yes   Yes
 alieztv             aliez.tv             Yes   Yes
 antenna             antenna.gr           --    Yes
+app17               17app.co             Yes   --
 ard_live            live.daserste.de     Yes   --    Streams may be geo-restricted to Germany.
 ard_mediathek       ardmediathek.de      Yes   Yes   Streams may be geo-restricted to Germany.
 artetv              arte.tv              Yes   Yes
@@ -40,6 +41,7 @@ cdnbg               - tv.bnt.bg          Yes   No    Streams may be geo-restrict
                     - kanal3.bg
                     - bitelevision.com
                     - nova.bg
+ceskatelevize       ceskatelevize.cz     Yes   Yes   Streams may be geo-restricted to Czechia.
 chaturbate          chaturbate.com       Yes   No
 cinergroup          - showtv.com.tr      Yes   No
                     - haberturk.com
@@ -63,6 +65,7 @@ dogus               - startv.com.tr      Yes   No
                     - ntvspor.net
                     - kralmuzik.com.tr
                     - ntv.com.tr
+                    - eurostartv.com.tr
 dommune             dommune.com          Yes   --
 douyutv             douyutv.com          Yes   --
 dplay               - dplay.se           --    Yes   Streams may be geo-restricted.
@@ -80,8 +83,11 @@ furstream           furstre.am           Yes   No
 gaminglive          gaminglive.tv        Yes   Yes
 gomexp              gomexp.com           Yes   No
 goodgame            goodgame.ru          Yes   No    Only HLS streams are available.
+gulli               replay.gulli.fr      Yes   Yes   Streams may be geo-restricted to France.
 hitbox              hitbox.tv            Yes   Yes
 huajiao             huajiao.com          Yes   No    
+huomao              huomao.com           Yes   No
+huya                huya.com             Yes   No    Temporarily only HLS streams available.
 ine                 ine.com              ---   Yes
 itvplayer           itv.com/itvplayer    Yes   Yes   Streams may be geo-restricted to Great Britain.
 kanal7              kanal7.com           Yes   No
@@ -115,6 +121,7 @@ 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.
 rtve                rtve.es              Yes   No
+rtvs                rtvs.sk              Yes   No    Streams may be geo-restricted to Slovakia.
 ruv                 ruv.is               Yes   Yes   Streams may be geo-restricted to Iceland.
 schoolism           schoolism.com        --    Yes   Requires a login and a subscription.
 seemeplay           seemeplay.ru         Yes   Yes
diff --git a/script/release.sh b/script/release.sh
index 76ddc592..b7803cb3 100755
--- a/script/release.sh
+++ b/script/release.sh
@@ -43,13 +43,6 @@ requirements() {
   fi
 }
 
-windows_binary() {
-  cd $CLI
-  ./script/makeinstaller.sh
-  cd ..
-  mv $CLI/build/nsis/$CLI-$1.exe .
-}
-
 # Clone and then change to user's upstream repo for pushing to master / opening PR's :)
 clone() {
   git clone ssh://git@github.com/$UPSTREAM_REPO/$CLI.git
@@ -102,8 +95,8 @@ changelog_rst() {
   DATE=$(date +"%Y-%m-%d")
   CHANGELOG=$(cat CHANGELOG.rst)
   HEADER="$CLI $1 ($DATE)"
-  HEADER="${HEADER}\n$(for i in $(seq 1 ${#HEADER}); do echo '-'; done)"
-  echo -e "$HEADER\n\n$CHANGES\n\n$CHANGELOG" >CHANGELOG.rst
+  UNDERLINE=$(printf %s "$HEADER" | tr -c '-' '[-*]')
+  echo -e "$HEADER\n$UNDERLINE\n$CHANGES\n\n$CHANGELOG" >CHANGELOG.rst
   echo "Changes have been written to CHANGELOG.rst"
   cd ..
 }
@@ -131,15 +124,10 @@ git_commit() {
 
 sign() {
   # Tarball it!
-  cp -r $CLI $CLI-$1
-  sudo rm -rf $CLI-$1/.git*
-  sudo tar czf $CLI-$1.tar.gz $CLI-$1
-  if [ $? -eq 0 ]; then
-        echo TARBALL OK
-  else
-        echo TARBALL FAIL
-        exit
-  fi
+  cd $CLI
+  python setup.py sdist
+  mv dist/$CLI-$1.tar.gz ..
+  cd ..
 
   # Sign it!
   echo -e "SIGN THE TARBALL!\n"
@@ -173,19 +161,6 @@ push() {
         exit
   fi
 
-  github-release upload \
-      --user $UPSTREAM_REPO \
-      --repo $CLI \
-      --tag $1 \
-      --name "$CLI-$1.exe" \
-      --file $CLI-$1.exe
-  if [ $? -eq 0 ]; then
-        echo WINDOWS BINARY UPLOAD OK
-  else 
-        echo WINDOWS BINARY UPLOAD FAIL
-        exit
-  fi
-
   github-release upload \
       --user $UPSTREAM_REPO \
       --repo $CLI \
@@ -217,7 +192,6 @@ push() {
   echo "!!!"
   echo "https://github.com/$UPSTREAM_REPO/$CLI/releases/edit/$1"
   echo "!!!"
-  echo "REMEMBER TO UPDATE DOCKER BUILDS! :D"
 }
 
 upload_pypi_test() {
@@ -272,8 +246,7 @@ main() {
   "Generate changelog for release"
   "Create PR"
   "Tarball and sign - requires gpg key"
-  "Create Windows binary"
-  "Upload the tarball and Windows binary and push to Github release page"
+  "Upload the tarball and source code to GitHub release page"
   "Test upload to pypi"
   "Upload to pypi"
   "Clean"
@@ -300,10 +273,7 @@ main() {
           "Tarball and sign - requires gpg key")
               sign $VERSION
               ;;
-          "Create Windows binary")
-              windows_binary $VERSION
-              ;;
-          "Upload the tarball and Windows binary and push to Github release page")
+          "Upload the tarball and source code to GitHub release page")
               push $VERSION
               ;;
           "Test upload to pypi")
diff --git a/setup.py b/setup.py
index 33b502ff..7f0a1687 100644
--- a/setup.py
+++ b/setup.py
@@ -4,6 +4,7 @@ from os import environ
 from os.path import abspath, dirname, join
 from setuptools import setup, find_packages
 from sys import version_info, path as sys_path
+import warnings
 
 deps = []
 
@@ -11,19 +12,18 @@ if version_info[0] == 2:
     # Require backport of concurrent.futures on Python 2
     deps.append("futures")
 
-    # Require backport of argparse on Python 2.6
-    if version_info[1] == 6:
+    if version_info[1] <= 6:
+        # Require backport of argparse on Python 2.6
         deps.append("argparse")
+        # require pyOpenSSL and idna for 2.6 compatibility
+        deps.append("pyOpenSSL")
+        deps.append("idna")
 
 # Require singledispatch on Python <3.4
 if version_info[0] == 2 or (version_info[0] == 3 and version_info[1] < 4):
     deps.append("singledispatch")
 
-# requests 2.0 does not work correctly on Python <2.6.3
-if (version_info[0] == 2 and version_info[1] == 6 and version_info[2] < 3):
-    deps.append("requests>=1.0,<2.0")
-else:
-    deps.append("requests>=1.0,!=2.12.0,!=2.12.1,<3.0")
+deps.append("requests>=2.2,!=2.12.0,!=2.12.1,<3.0")
 
 # this version of pycryptodome is known to work and has a Windows wheel for py2.7, py3.3-3.6
 deps.append("pycryptodome>=3.4.3,<4")
@@ -46,7 +46,7 @@ srcdir = join(dirname(abspath(__file__)), "src/")
 sys_path.insert(0, srcdir)
 
 setup(name="streamlink",
-      version="0.3.0",
+      version="0.3.2",
       description="Streamlink is command-line utility that extracts streams "
                   "from various services and pipes them into a video player of "
                   "choice.",
@@ -65,7 +65,6 @@ setup(name="streamlink",
                    "Environment :: Console",
                    "Operating System :: POSIX",
                    "Operating System :: Microsoft :: Windows",
-                   "Programming Language :: Python :: 2.6",
                    "Programming Language :: Python :: 2.7",
                    "Programming Language :: Python :: 3.3",
                    "Programming Language :: Python :: 3.4",
diff --git a/src/streamlink/__init__.py b/src/streamlink/__init__.py
index 6abdcaac..d7385050 100644
--- a/src/streamlink/__init__.py
+++ b/src/streamlink/__init__.py
@@ -9,10 +9,20 @@ An API is also provided that allows direct access to stream data.
 Full documentation is available at https://streamlink.github.io.
 
 """
+import warnings
+from sys import version_info
 
 
+if version_info[:2] == (2, 6):
+    warnings.warn(
+        "Python 2.6 is no longer supported by the Python core team, please "
+        "upgrade your Python. A future version of streamlink will drop "
+        "support for Python 2.6",
+        DeprecationWarning
+    )
+
 __title__ = "streamlink"
-__version__ = "0.3.0"
+__version__ = "0.3.2"
 __license__ = "Simplified BSD"
 __author__ = "Streamlink"
 __copyright__ = "Copyright 2016 Streamlink"
diff --git a/src/streamlink/plugin/api/http_session.py b/src/streamlink/plugin/api/http_session.py
index 28b07b62..46fa3dcd 100644
--- a/src/streamlink/plugin/api/http_session.py
+++ b/src/streamlink/plugin/api/http_session.py
@@ -1,10 +1,13 @@
+import time
 from requests import Session, __build__ as requests_version
 from requests.adapters import HTTPAdapter
 from requests.exceptions import RequestException
+
 from streamlink.packages.requests_file import FileAdapter
 
 try:
     from requests.packages.urllib3.util import Timeout
+
     TIMEOUT_ADAPTER_NEEDED = requests_version < 0x020300
 except ImportError:
     TIMEOUT_ADAPTER_NEEDED = False
@@ -142,25 +145,39 @@ class HTTPSession(Session):
         schema = kwargs.pop("schema", None)
         session = kwargs.pop("session", None)
         timeout = kwargs.pop("timeout", self.timeout)
+        total_retries = kwargs.pop("retries", 0)
+        retry_backoff = kwargs.pop("retry_backoff", 0.3)
+        retry_max_backoff = kwargs.pop("retry_max_backoff", 10.0)
+        retries = 0
 
         if session:
             headers.update(session.headers)
             params.update(session.params)
 
-        try:
-            res = Session.request(self, method, url,
-                                  headers=headers,
-                                  params=params,
-                                  timeout=timeout,
-                                  proxies=proxies,
-                                  *args, **kwargs)
-            if raise_for_status and res.status_code not in acceptable_status:
-                res.raise_for_status()
-        except (RequestException, IOError) as rerr:
-            err = exception("Unable to open URL: {url} ({err})".format(url=url,
-                                                                       err=rerr))
-            err.err = rerr
-            raise err
+        while True:
+            try:
+                res = Session.request(self, method, url,
+                                      headers=headers,
+                                      params=params,
+                                      timeout=timeout,
+                                      proxies=proxies,
+                                      *args, **kwargs)
+                if raise_for_status and res.status_code not in acceptable_status:
+                    res.raise_for_status()
+                break
+            except KeyboardInterrupt:
+                raise
+            except Exception as rerr:
+                if retries >= total_retries:
+                    err = exception("Unable to open URL: {url} ({err})".format(url=url,
+                                                                               err=rerr))
+                    err.err = rerr
+                    raise err
+                retries += 1
+                # back off retrying, but only to a maximum sleep time
+                delay = min(retry_max_backoff,
+                            retry_backoff * (2 ** (retries - 1)))
+                time.sleep(delay)
 
         if schema:
             res = schema.validate(res.text, name="response text", exception=PluginError)
diff --git a/src/streamlink/plugins/adultswim.py b/src/streamlink/plugins/adultswim.py
index df79df68..df8d6560 100644
--- a/src/streamlink/plugins/adultswim.py
+++ b/src/streamlink/plugins/adultswim.py
@@ -116,7 +116,7 @@ class AdultSwim(Plugin):
                 yield q, s
 
         else:
-            self.logger.error("Couldn't find the stream ID for this stream: {}".format(show))
+            self.logger.error("Couldn't find the stream ID for this stream: {0}".format(show))
 
     def _get_streams(self):
         # get the page
@@ -147,7 +147,7 @@ class AdultSwim(Plugin):
                     yield q, s
 
         else:
-            self.logger.error("Couldn't find the stream data for this stream: {}".format(show_name))
+            self.logger.error("Couldn't find the stream data for this stream: {0}".format(show_name))
 
 
 __plugin__ = AdultSwim
diff --git a/src/streamlink/plugins/afreecatv.py b/src/streamlink/plugins/afreecatv.py
index 13630008..d51a1873 100644
--- a/src/streamlink/plugins/afreecatv.py
+++ b/src/streamlink/plugins/afreecatv.py
@@ -6,27 +6,10 @@ from streamlink.plugin.api.utils import parse_query
 from streamlink.stream import RTMPStream
 
 
-VIEW_LIVE_API_URL = "http://api.afreeca.tv/live/view_live.php"
-VIEW_LIVE_API_URL_TW = "http://api.afreecatv.com.tw/live/view_live.php"
-VIEW_LIVE_API_URL_JP = "http://api.afreecatv.jp/live/view_live.php"
+VIEW_LIVE_API_URL = "http://api.{region}/live/view_live.php"
 
-_url_re = re.compile(r"http(s)?://(\w+\.)?(afreecatv.com.tw|afreeca.tv|afreecatv.jp)/(?P<channel>[\w\-_]+)")
-_url_re_tw = re.compile(r"http(s)?://(\w+\.)?(afreecatv.com.tw)/(?P<channel>[\w\-_]+)")
-_url_re_jp = re.compile(r"http(s)?://(\w+\.)?(afreecatv.jp)/(?P<channel>[\w\-_]+)")
-_flashvars_re = re.compile(r'<param name="flashvars" value="([^"]+)" />')
+_url_re = re.compile(r"https?://(\w+\.)?(?P<region>afreecatv\.com\.tw|afreeca\.tv|afreecatv\.jp)/(?P<channel>[\w\-_]+)")
 
-_flashvars_schema = validate.Schema(
-    validate.transform(_flashvars_re.findall),
-    validate.get(0),
-    validate.transform(parse_query),
-    validate.any(
-        {
-            "s": validate.text,
-            "id": validate.text
-        },
-        {}
-    )
-)
 _view_live_schema = validate.Schema(
     {
         "channel": {
@@ -47,26 +30,14 @@ class AfreecaTV(Plugin):
         return _url_re.match(url)
 
     def _get_streams(self):
-        flashvars = http.get(self.url, schema=_flashvars_schema)
-        if not flashvars:
-            return
+        match = _url_re.match(self.url)
 
         params = {
-            "rt": "json",
-            "lc": "en_US",
             "pt": "view",
-            "bpw": "",
-            "bid": flashvars["id"],
-            "adok": "",
-            "bno": ""
+            "bid": match.group("channel")
         }
 
-        if re.search(_url_re_tw, self.url):
-            res = http.get(VIEW_LIVE_API_URL_TW, params=params)
-        elif re.search(_url_re_jp, self.url):
-            res = http.get(VIEW_LIVE_API_URL_JP, params=params)
-        else:
-            res = http.get(VIEW_LIVE_API_URL, params=params)
+        res = http.get(VIEW_LIVE_API_URL.format(region=match.group("region")), params=params)
 
         streams = http.json(res, schema=_view_live_schema)
 
diff --git a/src/streamlink/plugins/app17.py b/src/streamlink/plugins/app17.py
new file mode 100644
index 00000000..b1e996ec
--- /dev/null
+++ b/src/streamlink/plugins/app17.py
@@ -0,0 +1,78 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate, useragents
+from streamlink.plugin.api.utils import parse_json
+from streamlink.stream import HLSStream, RTMPStream
+
+API_URL = "https://api-dsa.17app.co/api/v1/liveStreams/isUserOnLiveStream"
+ROOM_URL = "http://17app.co/share/live/{0}"
+
+_url_re = re.compile(r"http://(17app.co|17.media)/share/(?P<page>[^/]+)/(?P<channel>[^/&?]+)")
+_userid_re = re.compile(r'"userID"\s*:\s*"(.+?)"')
+_status_re = re.compile(r'"userIsOnLive"\s*:\s*([A-z]+)')
+_rtmp_re = re.compile(r'"url"\s*:\s*"(.+?)"')
+
+_user_api_schema = validate.Schema(validate.all(
+    {"data": validate.transform(parse_json)},
+    validate.get("data"),
+    {
+        "liveStreamID": int,
+        "userIsOnLive": int
+    }
+))
+
+
+class App17(Plugin):
+    @classmethod
+    def can_handle_url(cls, url):
+        return _url_re.match(url)
+
+    def _get_streams(self):
+        match = _url_re.match(self.url)
+        page = match.group("page")
+
+        http.headers.update({'User-Agent': useragents.CHROME})
+
+        if page == 'user':
+            res = http.get(self.url)
+            userid = _userid_re.search(res.text).group(1)
+            api = http.post(API_URL, data={"targetUserID": userid})
+            data = http.json(api, schema=_user_api_schema)
+            rid = data["liveStreamID"]
+            if rid == 0:
+                self.logger.info("Stream currently unavailable.")
+                return
+
+            url = ROOM_URL.format(rid)
+            res = http.get(url)
+        else:
+            res = http.get(self.url)
+
+        status = _status_re.search(res.text)
+        if not status:
+            return
+
+        if status.group(1) != 'true':
+            self.logger.info("Stream currently unavailable.")
+            return
+
+        url = _rtmp_re.search(res.text).group(1)
+        stream = RTMPStream(self.session, {
+                "rtmp": url,
+                "live": True
+                })
+        yield "live", stream
+
+        prefix = url.replace("rtmp:", "http:").replace(".flv", ".m3u8")
+        if '.m3u8' not in prefix:
+            url = prefix + "/playlist.m3u8"
+            for stream in HLSStream.parse_variant_playlist(self.session, url).items():
+                yield stream
+        else:
+            url = prefix
+            stream = HLSStream(self.session, url)
+            yield "live", stream
+
+
+__plugin__ = App17
diff --git a/src/streamlink/plugins/ard_mediathek.py b/src/streamlink/plugins/ard_mediathek.py
index 791808d4..4c605370 100644
--- a/src/streamlink/plugins/ard_mediathek.py
+++ b/src/streamlink/plugins/ard_mediathek.py
@@ -2,7 +2,7 @@ import re
 
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
-from streamlink.stream import HTTPStream, HDSStream, RTMPStream
+from streamlink.stream import HTTPStream, HDSStream, HLSStream
 
 MEDIA_URL = "http://www.ardmediathek.de/play/media/{0}"
 SWF_URL = "http://www.ardmediathek.de/ard/static/player/base/flash/PluginFlash.swf"
@@ -16,7 +16,7 @@ QUALITY_MAP = {
 }
 
 _url_re = re.compile(r"http(s)?://(\w+\.)?ardmediathek.de/tv")
-_media_id_re = re.compile(r"/play/config/(\d+)")
+_media_id_re = re.compile(r"/play/(?:media|config)/(\d+)")
 _media_schema = validate.Schema({
     "_mediaArray": [{
         "_mediaStreamArray": [{
@@ -33,6 +33,10 @@ _smil_schema = validate.Schema(
             validate.get("base"),
             validate.url(scheme="http")
         ),
+        "cdn": validate.all(
+            validate.xml_find("head/meta"),
+            validate.get("cdn")
+        ),
         "videos": validate.all(
             validate.xml_findall("body/seq/video"),
             [validate.get("src")]
@@ -61,17 +65,8 @@ class ard_mediathek(Plugin):
         url = info["_stream"] + HDCORE_PARAMETER
         return HDSStream.parse_manifest(self.session, url, pvswf=SWF_URL).items()
 
-    def _get_rtmp_streams(self, info):
-        name = QUALITY_MAP.get(info["_quality"], "live")
-        params = {
-            "rtmp": info["_server"].strip(),
-            "playpath": info["_stream"],
-            "pageUrl": self.url,
-            "swfVfy": SWF_URL,
-            "live": True
-        }
-        stream = RTMPStream(self.session, params)
-        yield name, stream
+    def _get_hls_streams(self, info):
+        return HLSStream.parse_variant_playlist(self.session, info["_stream"]).items()
 
     def _get_smil_streams(self, info):
         res = http.get(info["_stream"])
@@ -79,7 +74,7 @@ class ard_mediathek(Plugin):
 
         for video in smil["videos"]:
             url = "{0}/{1}{2}".format(smil["base"], video, HDCORE_PARAMETER)
-            streams = HDSStream.parse_manifest(self.session, url, pvswf=SWF_URL)
+            streams = HDSStream.parse_manifest(self.session, url, pvswf=SWF_URL, is_akamai=smil["cdn"] == "akamai")
 
             # TODO: Replace with "yield from" when dropping Python 2.
             for stream in streams.items():
@@ -93,6 +88,8 @@ class ard_mediathek(Plugin):
         else:
             return
 
+        self.logger.debug("Found media id: {0}", media_id)
+
         res = http.get(MEDIA_URL.format(media_id))
         media = http.json(res, schema=_media_schema)
 
@@ -104,25 +101,23 @@ class ard_mediathek(Plugin):
                     if not stream_:
                         continue
                     stream_ = stream_[0]
-                stream_ = stream_.strip()
 
-                if server.startswith("rtmp://"):
-                    parser = self._get_rtmp_streams
-                    parser_name = "RTMP"
-                elif stream_.endswith(".f4m"):
+                if stream_.endswith(".f4m"):
                     parser = self._get_hds_streams
                     parser_name = "HDS"
                 elif stream_.endswith(".smil"):
                     parser = self._get_smil_streams
                     parser_name = "SMIL"
+                elif stream_.endswith(".m3u8"):
+                    parser = self._get_hls_streams
+                    parser_name = "HLS"
                 elif stream_.startswith("http"):
                     parser = self._get_http_streams
                     parser_name = "HTTP"
 
                 try:
-                    # TODO: Replace with "yield from" when dropping Python 2.
-                    for stream in parser(stream):
-                        yield stream
+                    for s in parser(stream):
+                        yield s
                 except IOError as err:
                     self.logger.error("Failed to extract {0} streams: {1}",
                                       parser_name, err)
diff --git a/src/streamlink/plugins/bigo.py b/src/streamlink/plugins/bigo.py
index e29c7a85..98101c78 100644
--- a/src/streamlink/plugins/bigo.py
+++ b/src/streamlink/plugins/bigo.py
@@ -22,12 +22,12 @@ class BigoStream(Stream):
             self.sid = int(sid)
             self.uid = int(uid)
         except ValueError:
-            raise PluginError("invalid sid or uid parameter for Bigo Stream: {}/{}".format(self.sid, self.uid))
+            raise PluginError("invalid sid or uid parameter for Bigo Stream: {0}/{1}".format(self.sid, self.uid))
         self.ip = ip
         try:
             self.port = int(port)
         except ValueError:
-            raise PluginError("invalid port number for Bigo Stream: {}:{}".format(self.ip, self.port))
+            raise PluginError("invalid port number for Bigo Stream: {0}:{1}".format(self.ip, self.port))
         self.con = None
 
     def open(self):
diff --git a/src/streamlink/plugins/bongacams.py b/src/streamlink/plugins/bongacams.py
index ae64e100..5ba390e4 100644
--- a/src/streamlink/plugins/bongacams.py
+++ b/src/streamlink/plugins/bongacams.py
@@ -42,14 +42,6 @@ amf_msg_schema = validate.Schema({
 })
 
 
-class AMFMessage2(AMFMessage):
-    def _serialize(self, packet):
-        packet += AMF0String(self.target_uri)
-        packet += AMF0String(self.response_uri)
-        packet += U32BE(AMF0Value.size(self.value))
-        packet += AMF0Value.pack(self.value)
-
-
 class bongacams(Plugin):
     @classmethod
     def can_handle_url(self, url):
@@ -75,11 +67,11 @@ class bongacams(Plugin):
         # get swf url and cookies
         r = http_session.get(urlunparse((stream_page_scheme, stream_page_domain, stream_page_path, '', '', '')))
 
-        # redirect to profile page means stream is offlie
+        # redirect to profile page means stream is offline
         if '/profile/' in r.url:
             raise NoStreamsError(self.url)
         if not r.ok:
-            self.logger.debug("Status code for {}: {}", r.url, r.status_code)
+            self.logger.debug("Status code for {0}: {1}", r.url, r.status_code)
             raise NoStreamsError(self.url)
         if len(http_session.cookies) == 0:
             raise PluginError("Can't get a cookies")
@@ -96,15 +88,15 @@ class bongacams(Plugin):
         match = swf_re.search(r.text)
         if match:
             swf_url = urljoin(baseurl, match.group())
-            self.logger.debug("swf url found: {}", swf_url)
+            self.logger.debug("swf url found: {0}", swf_url)
         else:
             # most likely it means that country/region banned
             # can try use default swf-url
             swf_url = urljoin(baseurl, CONST_DEFAULT_SWF_LOCATION)
-            self.logger.debug("swf url not found. Will try {}", swf_url)
+            self.logger.debug("swf url not found. Will try {0}", swf_url)
 
         # create amf query
-        amf_message = AMFMessage2("svDirectAmf.getRoomData", "/1", [stream_page_path, is_paid_show])
+        amf_message = AMFMessage("svDirectAmf.getRoomData", "/1", [stream_page_path, is_paid_show])
         amf_packet = AMFPacket(version=0)
         amf_packet.messages.append(amf_message)
 
@@ -115,7 +107,7 @@ class bongacams(Plugin):
         http_session.close()
 
         if r.status_code != 200:
-            raise PluginError("unexpected status code for {}: {}", r.url, r.status_code)
+            raise PluginError("unexpected status code for {0}: {1}", r.url, r.status_code)
 
         amf_response = AMFPacket.deserialize(BytesIO(r.content))
 
@@ -123,12 +115,11 @@ class bongacams(Plugin):
             raise PluginError("unexpected response from amf gate")
 
         stream_source_info = amf_msg_schema.validate(amf_response.messages[0].value)
-        self.logger.debug("source stream info:\n{}", stream_source_info)
+        self.logger.debug("source stream info:\n{0}", stream_source_info)
 
         stream_params = {
             "live": True,
             "realtime": True,
-            "verbose": True,
             "flashVer": CONST_FLASH_VER,
             "swfUrl": swf_url,
             "tcUrl": stream_source_info['localData']['NC_ConnUrl'],
@@ -142,7 +133,7 @@ class bongacams(Plugin):
                      "S:{0}".format(stream_source_info['localData']['dataKey'])]
         }
 
-        self.logger.debug("Stream params:\n{}", stream_params)
+        self.logger.debug("Stream params:\n{0}", stream_params)
         stream = RTMPStream(self.session, stream_params)
 
         return {'live': stream}
diff --git a/src/streamlink/plugins/ceskatelevize.py b/src/streamlink/plugins/ceskatelevize.py
new file mode 100644
index 00000000..44ac2239
--- /dev/null
+++ b/src/streamlink/plugins/ceskatelevize.py
@@ -0,0 +1,139 @@
+"""
+Plugin for Czech TV (Ceska televize).
+
+Following channels are working:
+    * CT1 - http://www.ceskatelevize.cz/ct1/zive/
+    * CT2 - http://www.ceskatelevize.cz/ct2/zive/
+    * CT24 - http://www.ceskatelevize.cz/ct24/
+    * CT sport - http://www.ceskatelevize.cz/sport/zive-vysilani/
+    * CT Decko - http://decko.ceskatelevize.cz/zive/
+    * CT Art - http://www.ceskatelevize.cz/art/zive/
+
+Additionaly, videos from iVysilani archive should work as well.
+"""
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import HLSStream
+from streamlink.exceptions import PluginError
+
+_url_re = re.compile(
+    r'http(s)?://([^.]*.)?ceskatelevize.cz'
+)
+_player_re = re.compile(
+    r'ivysilani/embed/iFramePlayer[^"]+'
+)
+_hash_re = re.compile(
+    r'hash:"([0-9a-z]+)"'
+)
+_playlist_info_re = re.compile(
+    r'{"type":"([a-z]+)","id":"([0-9]+)"'
+)
+_playlist_url_schema = validate.Schema({
+    "url": validate.any(
+                validate.url(),
+                "error_region"
+            )
+})
+_playlist_schema = validate.Schema({
+    "playlist": [{
+        "streamUrls": {
+            "main": validate.url(),
+        }
+    }]
+})
+
+
+def _find_playlist_info(response):
+    """
+    Finds playlist info (type, id) in HTTP response.
+
+    :param response: Response object.
+    :returns: Dictionary with type and id.
+    """
+    values = {}
+    matches = _playlist_info_re.search(response.text)
+    if matches:
+        values['type'] = matches.group(1)
+        values['id'] = matches.group(2)
+
+    return values
+
+
+def _find_player_url(response):
+    """
+    Finds embedded player url in HTTP response.
+
+    :param response: Response object.
+    :returns: Player url (str).
+    """
+    url = ''
+    matches = _player_re.search(response.text)
+    if matches:
+        tmp_url = matches.group(0).replace('&amp;', '&')
+        if 'hash' not in tmp_url:
+            # there's no hash in the URL, try to find it
+            matches = _hash_re.search(response.text)
+            if matches:
+                url = tmp_url+'&hash='+matches.group(1)
+        else:
+            url = tmp_url
+
+    return 'http://ceskatelevize.cz/'+url
+
+
+class Ceskatelevize(Plugin):
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return _url_re.match(url)
+
+    def _get_streams(self):
+        # fetch requested url and find playlist info
+        response = http.get(self.url)
+        info = _find_playlist_info(response)
+
+        if not info:
+            # playlist info not found, let's try to find player url
+            player_url = _find_player_url(response)
+            if not player_url:
+                raise PluginError('Cannot find playlist info or player url!')
+
+            # get player url and try to find playlist info in it
+            response = http.get(player_url)
+            info = _find_playlist_info(response)
+            if not info:
+                raise PluginError('Cannot find playlist info in the player url!')
+
+        data = {
+            'playlist[0][type]': info['type'],
+            'playlist[0][id]': info['id'],
+            'requestUrl': '/ivysilani/embed/iFramePlayerCT24.php',
+            'requestSource': 'iVysilani',
+            'type': 'html'
+        }
+        headers = {
+            'x-addr': '127.0.0.1',
+        }
+
+        # fetch playlist url
+        response = http.post(
+            'http://www.ceskatelevize.cz/ivysilani/ajax/get-client-playlist',
+            data=data,
+            headers=headers
+        )
+        json_data = http.json(response, schema=_playlist_url_schema)
+
+        if json_data['url'] == "error_region":
+            self.logger.error("This stream is not available in your territory")
+            return
+
+        # fetch playlist
+        response = http.post(json_data['url'])
+        json_data = http.json(response, schema=_playlist_schema)
+        playlist = json_data['playlist'][0]['streamUrls']['main']
+        return HLSStream.parse_variant_playlist(self.session, playlist)
+
+
+__plugin__ = Ceskatelevize
diff --git a/src/streamlink/plugins/connectcast.py b/src/streamlink/plugins/connectcast.py
index 8df194af..88a50831 100644
--- a/src/streamlink/plugins/connectcast.py
+++ b/src/streamlink/plugins/connectcast.py
@@ -3,11 +3,10 @@ import json
 
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
-from streamlink.stream import RTMPStream
+from streamlink.stream import HLSStream
 
 _url_re = re.compile(r"http(?:s)?://connectcast.tv/(\w+)?")
-_stream_re = re.compile(r'<video src="mp4:(.*?)"')
-_stream_url = "http://connectcast.tv/channel/stream/{channel}"
+_stream_re = re.compile(r'file.*?:.*?"(.*?)"')
 
 
 class ConnectCast(Plugin):
@@ -16,16 +15,13 @@ class ConnectCast(Plugin):
         return _url_re.match(url)
 
     def _get_streams(self):
-        url_match = _url_re.match(self.url)
-        stream_url = _stream_url.format(channel=url_match.group(1))
-        res = self.session.http.get(stream_url)
+        res = self.session.http.get(self.url)
         match = _stream_re.search(res.text)
         if match:
-            params = dict(rtmp="rtmp://stream.connectcast.tv/live",
-                          playpath=match.group(1),
-                          live=True)
-
-            return dict(live=RTMPStream(self.session, params))
+            streams = {}
+            streams = HLSStream.parse_variant_playlist(self.session, match.group(1))
+            self.logger.info("_get_stream")
+            return streams
 
 
 __plugin__ = ConnectCast
diff --git a/src/streamlink/plugins/crunchyroll.py b/src/streamlink/plugins/crunchyroll.py
index 0e081f03..9e723f33 100644
--- a/src/streamlink/plugins/crunchyroll.py
+++ b/src/streamlink/plugins/crunchyroll.py
@@ -9,7 +9,7 @@ from streamlink.stream import HLSStream
 
 API_URL = "https://api.crunchyroll.com/{0}.0.json"
 API_DEFAULT_LOCALE = "en_US"
-API_USER_AGENT = "Mozilla/5.0 (iPhone; iPhone OS 8.3.0; {})"
+API_USER_AGENT = "Mozilla/5.0 (iPhone; iPhone OS 8.3.0; {0})"
 API_HEADERS = {
     "Host": "api.crunchyroll.com",
     "Accept-Encoding": "gzip, deflate",
@@ -205,7 +205,8 @@ class Crunchyroll(Plugin):
         "username": None,
         "password": None,
         "purge_credentials": None,
-        "locale": None
+        "locale": None,
+        "session_id": None,
     })
 
     @classmethod
@@ -281,13 +282,14 @@ class Crunchyroll(Plugin):
         if self.options.get("purge_credentials"):
             self.cache.set("session_id", None, 0)
             self.cache.set("auth", None, 0)
+            self.cache.set("session_id", None, 0)
 
         current_time = datetime.datetime.utcnow()
         device_id = self._get_device_id()
         # use the crunchyroll locale as an override, for backwards compatibility
         locale = self.get_option("locale") or self.session.localization.language_code
         api = CrunchyrollAPI(
-            self.cache.get("session_id"), self.cache.get("auth"), locale
+            self.options.get("session_id") or self.cache.get("session_id"), self.cache.get("auth"), locale
         )
 
         self.logger.debug("Creating session with locale: {0}", locale)
diff --git a/src/streamlink/plugins/dogus.py b/src/streamlink/plugins/dogus.py
index 9eabfd24..4d58d33b 100644
--- a/src/streamlink/plugins/dogus.py
+++ b/src/streamlink/plugins/dogus.py
@@ -7,6 +7,7 @@ from streamlink.plugin.api import http
 from streamlink.plugin.api import validate
 from streamlink.stream import HDSStream
 from streamlink.stream import HLSStream
+from streamlink.utils import update_scheme
 
 
 class Dogus(Plugin):
@@ -20,13 +21,14 @@ class Dogus(Plugin):
             ntv.com.tr/canli-yayin/ntv|
             ntvspor.net/canli-yayin|
             kralmuzik.com.tr/tv/kral-tv|
-            kralmuzik.com.tr/tv/kral-pop-tv
+            kralmuzik.com.tr/tv/kral-pop-tv|
+            eurostartv.com.tr/canli-izle
         )/?""", re.VERBOSE)
-    mobile_url_re = re.compile(r"""(?P<q>[\"'])(?P<url>https?://[^'"]*?/live/hls/[^'"]*?\?token=)
+    mobile_url_re = re.compile(r"""(?P<q>["'])(?P<url>(https?:)?//[^'"]*?/live/hls/[^'"]*?\?token=)
                                    (?P<token>[^'"]*?)(?P=q)""", re.VERBOSE)
-    desktop_url_re = re.compile(r"""(?P<q>[\"'])(?P<url>https?://[^'"]*?/live/hds/[^'"]*?\?token=)
+    desktop_url_re = re.compile(r"""(?P<q>["'])(?P<url>(https?:)?//[^'"]*?/live/hds/[^'"]*?\?token=)
                                     (?P<token>[^'"]*?)(?P=q)""", re.VERBOSE)
-    token_re = re.compile(r"""token=(?P<q>[\"'])(?P<token>[^'"]*?)(?P=q)""")
+    token_re = re.compile(r"""token=(?P<q>["'])(?P<token>[^'"]*?)(?P=q)""")
 
     hds_schema = validate.Schema(validate.all(
         {
@@ -69,8 +71,8 @@ class Dogus(Plugin):
         mobile_url_m = self.mobile_url_re.search(res.text)
         desktop_url_m = self.desktop_url_re.search(res.text)
 
-        desktop_url = desktop_url_m and desktop_url_m.group("url")
-        mobile_url = mobile_url_m and mobile_url_m.group("url")
+        desktop_url = desktop_url_m and  update_scheme(self.url, desktop_url_m.group("url"))
+        mobile_url = mobile_url_m and  update_scheme(self.url, mobile_url_m.group("url"))
 
         token = (desktop_url_m and desktop_url_m.group("token")) or (mobile_url_m and mobile_url_m.group("token"))
         if not token:
diff --git a/src/streamlink/plugins/douyutv.py b/src/streamlink/plugins/douyutv.py
index be28ee9a..975990e4 100644
--- a/src/streamlink/plugins/douyutv.py
+++ b/src/streamlink/plugins/douyutv.py
@@ -6,10 +6,9 @@ import uuid
 from requests.adapters import HTTPAdapter
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import http, validate
+from streamlink.plugin.api import http, validate, useragents
 from streamlink.stream import HTTPStream
 
-USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36"
 MAPI_URL = "https://m.douyu.com/html5/live?roomId={0}"
 LAPI_URL = "https://www.douyu.com/lapi/live/getPlay/{0}"
 LAPI_SECRET = "A12Svb&%1UUmf@hC"
@@ -26,7 +25,7 @@ _url_re = re.compile(r"""
     /(?P<channel>[^/]+)
 """, re.VERBOSE)
 
-_room_id_re = re.compile(r'"room_id"\s*:\s*(\d+),')
+_room_id_re = re.compile(r'"room_id\\*"\s*:\s*(\d+),')
 _room_id_alt_re = re.compile(r'data-room_id="(\d+)"')
 
 _room_id_schema = validate.Schema(
@@ -94,7 +93,7 @@ class Douyutv(Plugin):
         match = _url_re.match(self.url)
         channel = match.group("channel")
 
-        http.headers.update({'User-Agent': USER_AGENT})
+        http.headers.update({'User-Agent': useragents.CHROME})
         http.verify = False
         http.mount('https://', HTTPAdapter(max_retries=99))
 
@@ -109,9 +108,11 @@ class Douyutv(Plugin):
         res = http.get(MAPI_URL.format(channel))
         room = http.json(res, schema=_room_schema)
         if not room:
+            self.logger.info("Not a valid room url.")
             return
 
         if room["show_status"] != SHOW_STATUS_ONLINE:
+            self.logger.info("Stream currently unavailable.")
             return
 
         ts = int(time.time() / 60)
diff --git a/src/streamlink/plugins/filmon.py b/src/streamlink/plugins/filmon.py
index e7edd269..2c770f19 100644
--- a/src/streamlink/plugins/filmon.py
+++ b/src/streamlink/plugins/filmon.py
@@ -1,150 +1,144 @@
 import re
 
-from streamlink.compat import urlparse
+import time
+
+from streamlink import StreamError
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
-from streamlink.stream import HLSStream, RTMPStream
-
-CHINFO_URL = "http://www.filmon.com/ajax/getChannelInfo"
-SWF_URL = "http://www.filmon.com/tv/modules/FilmOnTV/files/flashapp/filmon/FilmonPlayer.swf"
-VODINFO_URL = "http://www.filmon.com/vod/info/{0}"
-
-AJAX_HEADERS = {
-    "Referer": "http://www.filmon.com",
-    "X-Requested-With": "XMLHttpRequest",
-    "User-Agent": "Mozilla/5.0"
-}
-QUALITY_WEIGHTS = {
-    "high": 720,
-    "low": 480
-}
-STREAM_TYPES = ("hls", "rtmp")
-
-_url_re = re.compile(r"http(s)?://(\w+\.)?filmon.com/(channel|tv|vod)/")
-_channel_id_re = re.compile(r"/channels/(\d+)/extra_big_logo.png")
-_vod_id_re = re.compile(r"movie_id=(\d+)")
-
-_channel_schema = validate.Schema({
-    "streams": [{
-        "name": validate.text,
+from streamlink.stream import HLSStream
+
+
+class FilmOnHLS(HLSStream):
+    __shortname__ = "hls-filmon"
+
+    def __init__(self, session_, channel=None, vod_id=None, quality="high", **args):
+        super(FilmOnHLS, self).__init__(session_, None, **args)
+        self.logger = self.session.logger.new_module("stream.hls-filmon")
+        self.channel = channel
+        self.vod_id = vod_id
+        if self.channel is None and self.vod_id is None:
+            raise ValueError("channel or vod_id must be set")
+        self.quality = quality
+        self.api = FilmOnAPI()
+        self._url = None
+        self.watch_timeout = 0
+
+    def _get_stream_data(self):
+        if self.channel:
+            self.logger.debug("Reloading FilmOn channel playlist: {0}", self.channel)
+            data = self.api.channel(self.channel)
+            for stream in data["streams"]:
+                yield stream
+        elif self.vod_id:
+            self.logger.debug("Reloading FilmOn VOD playlist: {0}", self.vod_id)
+            data = self.api.vod(self.vod_id)
+            for _, stream in data["streams"].items():
+                yield stream
+
+    @property
+    def url(self):
+        # If the watch timeout has passed then refresh the playlist from the API
+        if int(time.time()) >= self.watch_timeout:
+            for stream in self._get_stream_data():
+                if stream["quality"] == self.quality:
+                    self.watch_timeout = int(time.time()) + stream["watch-timeout"]
+                    self._url = stream["url"]
+                    return self._url
+            raise StreamError("cannot refresh FilmOn HLS Stream playlist")
+        else:
+            return self._url
+
+    def to_url(self):
+        url = self.url
+        expires = self.watch_timeout - time.time()
+        if expires < 0:
+            raise TypeError("Stream has expired and cannot be converted to a URL")
+        return url
+
+
+class FilmOnAPI(object):
+    channel_url = "http://www.filmon.com/api-v2/channel/{0}?protocol=hls"
+    vod_url = "http://www.filmon.com/vod/info/{0}"
+
+    stream_schema = {
         "quality": validate.text,
-        "url": validate.url(scheme=validate.any("http", "rtmp"))
-    }]
-})
-_vod_schema = validate.Schema(
-    {
-        "data": {
-            "streams": {
-                validate.text: {
-                    "name": validate.text,
-                    "url": validate.url(scheme=validate.any("http", "rtmp"))
-                }
+        "url": validate.url(),
+        "watch-timeout": int
+    }
+    api_schema = validate.Schema(
+        {
+            "data": {
+                "streams": validate.any(
+                    {validate.text: stream_schema},
+                    [stream_schema]
+                )
             }
-        }
-    },
-    validate.get("data")
-)
+        },
+        validate.get("data")
+    )
 
+    def channel(self, channel):
+        res = http.get(self.channel_url.format(channel))
+        return http.json(res, schema=self.api_schema)
 
-def ajax(*args, **kwargs):
-    kwargs["headers"] = AJAX_HEADERS
-    return http.post(*args, **kwargs)
+    def vod(self, vod_id):
+        res = http.get(self.vod_url.format(vod_id))
+        return http.json(res, schema=self.api_schema)
 
 
 class Filmon(Plugin):
+    url_re = re.compile(r"""https?://(?:\w+\.)?filmon.(?:tv|com)/
+        (?:
+            (tv|channel)/(?P<channel>[^/]+)|
+            vod/view/(?P<vod_id>\d+)-|
+            group/
+        )
+    """, re.VERBOSE)
+
+    _channel_id_re = re.compile(r'channel_id\s*?=\s*"(\d+)"')
+    _channel_id_schema = validate.Schema(
+        validate.transform(_channel_id_re.search),
+        validate.any(None, validate.get(1))
+    )
+
+    quality_weights = {
+        "high": 720,
+        "low": 480
+    }
+
+    def __init__(self, url):
+        super(Filmon, self).__init__(url)
+        self.api = FilmOnAPI()
+
     @classmethod
     def can_handle_url(cls, url):
-        return _url_re.match(url)
+        return cls.url_re.match(url) is not None
 
     @classmethod
     def stream_weight(cls, key):
-        weight = QUALITY_WEIGHTS.get(key)
+        weight = cls.quality_weights.get(key)
         if weight:
             return weight, "filmon"
 
         return Plugin.stream_weight(key)
 
-    def _create_rtmp_stream(self, stream, live=True):
-        rtmp = stream["url"]
-        playpath = stream["name"]
-        parsed = urlparse(rtmp)
-        if parsed.query:
-            app = "{0}?{1}".format(parsed.path[1:], parsed.query)
-        else:
-            app = parsed.path[1:]
-
-        if playpath.endswith(".mp4"):
-            playpath = "mp4:" + playpath
-
-        params = {
-            "rtmp": rtmp,
-            "pageUrl": self.url,
-            "swfUrl": SWF_URL,
-            "playpath": playpath,
-            "app": app,
-        }
-        if live:
-            params["live"] = True
-
-        return RTMPStream(self.session, params)
-
-    def _get_live_streams(self, channel_id):
-        params = {"channel_id": channel_id}
-        for stream_type in STREAM_TYPES:
-            cookies = {"flash-player-type": stream_type}
-            res = ajax(CHINFO_URL, cookies=cookies, data=params)
-            channel = http.json(res, schema=_channel_schema)
-
-            # TODO: Replace with "yield from" when dropping Python 2.
-            for stream in self._parse_live_streams(channel):
-                yield stream
-
-    def _parse_live_streams(self, channel):
-        for stream in channel["streams"]:
-            name = stream["quality"]
-            scheme = urlparse(stream["url"]).scheme
-
-            if scheme == "http":
-                try:
-                    streams = HLSStream.parse_variant_playlist(self.session, stream["url"])
-                    for __, stream in streams.items():
-                        yield name, stream
-
-                except IOError as err:
-                    self.logger.error("Failed to extract HLS stream '{0}': {1}", name, err)
-
-            elif scheme == "rtmp":
-                yield name, self._create_rtmp_stream(stream)
-
-    def _get_vod_streams(self, movie_id):
-        for stream_type in STREAM_TYPES:
-            cookies = {"flash-player-type": stream_type}
-            res = ajax(VODINFO_URL.format(movie_id), cookies=cookies)
-            vod = http.json(res, schema=_vod_schema)
-
-            # TODO: Replace with "yield from" when dropping Python 2.
-            for stream in self._parse_vod_streams(vod):
-                yield stream
-
-    def _parse_vod_streams(self, vod):
-        for name, stream in vod["streams"].items():
-            scheme = urlparse(stream["url"]).scheme
-
-            if scheme == "http":
-                yield name, HLSStream(self.session, stream["url"])
-            elif scheme == "rtmp":
-                yield name, self._create_rtmp_stream(stream, live=False)
-
     def _get_streams(self):
-        res = http.get(self.url)
+        url_m = self.url_re.match(self.url)
+
+        channel = url_m and url_m.group("channel")
+        vod_id = url_m and url_m.group("vod_id")
 
-        match = _vod_id_re.search(res.text)
-        if match:
-            return self._get_vod_streams(match.group(1))
+        if vod_id:
+            data = self.api.vod(vod_id)
+            for _, stream in data["streams"].items():
+                yield stream["quality"], FilmOnHLS(self.session, vod_id=vod_id, quality=stream["quality"])
 
-        match = _channel_id_re.search(res.text)
-        if match:
-            return self._get_live_streams(match.group(1))
+        else:
+            if not channel:
+                channel = http.get(self.url, schema=self._channel_id_schema)
+            data = self.api.channel(channel)
+            for stream in data["streams"]:
+                yield stream["quality"], FilmOnHLS(self.session, channel=channel, quality=stream["quality"])
 
 
 __plugin__ = Filmon
diff --git a/src/streamlink/plugins/foxtr.py b/src/streamlink/plugins/foxtr.py
index da7eac3a..c0ceebe3 100644
--- a/src/streamlink/plugins/foxtr.py
+++ b/src/streamlink/plugins/foxtr.py
@@ -11,8 +11,8 @@ class FoxTR(Plugin):
     """
     Support for Turkish Fox live stream: http://www.fox.com.tr/canli-yayin
     """
-    url_re = re.compile(r"http?://www.fox.com.tr/canli-yayin")
-    playervars_re = re.compile(r"desktop.*?:.*?\[.*?\{.*?src.*?:.*?'(.*?)'", re.DOTALL)
+    url_re = re.compile(r"https?://www.fox.com.tr/canli-yayin")
+    playervars_re = re.compile(r"desktop\s*:\s*\[\s*\{\s*src\s*:\s*'(.*?)'", re.DOTALL)
 
     @classmethod
     def can_handle_url(cls, url):
diff --git a/src/streamlink/plugins/gulli.py b/src/streamlink/plugins/gulli.py
new file mode 100644
index 00000000..824fdeb9
--- /dev/null
+++ b/src/streamlink/plugins/gulli.py
@@ -0,0 +1,84 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import HLSStream, HTTPStream
+from streamlink.utils import parse_json
+
+
+class Gulli(Plugin):
+    LIVE_PLAYER_URL = 'http://replay.gulli.fr/jwplayer/embedstreamtv'
+    VOD_PLAYER_URL = 'http://replay.gulli.fr/jwplayer/embed/{0}'
+
+    _url_re = re.compile(r'http://replay\.gulli\.fr/(?:Direct|.+/(?P<video_id>VOD[0-9]+))')
+    _playlist_re = re.compile(r'sources: (\[.+?\])', re.DOTALL)
+    _vod_video_index_re = re.compile(r'jwplayer\(idplayer\).playlistItem\((?P<video_index>[0-9]+)\)')
+    _mp4_bitrate_re = re.compile(r'.*_(?P<bitrate>[0-9]+)\.mp4')
+
+    _video_schema = validate.Schema(
+        validate.all(
+            validate.transform(lambda x: re.sub(r'"?file"?:\s*[\'"](.+?)[\'"]', r'"file": "\1"', x, flags=re.DOTALL)),
+            validate.transform(parse_json),
+            [
+                validate.Schema({
+                    'file': validate.url()
+                })
+            ]
+        )
+    )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return Gulli._url_re.match(url)
+
+    def _get_streams(self):
+        match = self._url_re.match(self.url)
+        video_id = match.group('video_id')
+        if video_id is not None:
+            # VOD
+            live = False
+            player_url = self.VOD_PLAYER_URL.format(video_id)
+        else:
+            # Live
+            live = True
+            player_url = self.LIVE_PLAYER_URL
+
+        res = http.get(player_url)
+        playlist = re.findall(self._playlist_re, res.text)
+        index = 0
+        if not live:
+            # Get the index for the video on the playlist
+            match = self._vod_video_index_re.search(res.text)
+            if match is None:
+                return
+            index = int(match.group('video_index'))
+
+        if not playlist:
+            return
+        videos = self._video_schema.validate(playlist[index])
+
+        for video in videos:
+            video_url = video['file']
+
+            # Ignore non-supported MSS streams
+            if 'isml/Manifest' in video_url:
+                continue
+
+            try:
+                if '.m3u8' in video_url:
+                    for stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
+                        yield stream
+                elif '.mp4' in video_url:
+                    match = self._mp4_bitrate_re.match(video_url)
+                    if match is not None:
+                        bitrate = '%sk' % match.group('bitrate')
+                    else:
+                        bitrate = 'vod'
+                    yield bitrate, HTTPStream(self.session, video_url)
+            except IOError as err:
+                if '403 Client Error' in str(err):
+                    self.logger.error('Failed to access stream, may be due to geo-restriction')
+                raise
+
+
+__plugin__ = Gulli
diff --git a/src/streamlink/plugins/huomao.py b/src/streamlink/plugins/huomao.py
new file mode 100644
index 00000000..298210f0
--- /dev/null
+++ b/src/streamlink/plugins/huomao.py
@@ -0,0 +1,103 @@
+"""
+NOTE: Since a documented API is nowhere to be found for Huomao; this plugin
+simply extracts the videos stream_id, stream_url and stream_quality by
+scraping the HTML and JS of one of Huomaos mobile webpages.
+
+When viewing a stream on huomao.com, the base URL references a room_id. This
+room_id is mapped one-to-one to a stream_id which references the actual .flv
+video. Both stream_id, stream_url and stream_quality can be found in the
+HTML and JS source of the mobile_page. Since one stream can occur in many
+different qualities, we scrape all stream_url and stream_quality occurences
+and return each option to the user.
+"""
+
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.stream import HTTPStream
+
+# URL pattern for recognizing inputed Huomao.tv / Huomao.com URL.
+url_re = re.compile(r"""
+    (http(s)?://)?
+    (www\.)?
+    huomao
+    (\.tv|\.com)
+    /(?P<room_id>\d+)
+""", re.VERBOSE)
+
+# URL used to retrive the stream_id, stream_url and stream_quality based of
+# a room_id.
+mobile_url = "http://www.huomao.com/mobile/mob_live/{0}"
+
+# Pattern for extracting the stream_id from the mobile_url HTML.
+#
+# Example from HTML:
+#   <input id="html_stream" value="efmrCH" type="hidden">
+stream_id_pattern = re.compile(r'id=\"html_stream\" value=\"(?P<stream_id>\w+)\"')
+
+# Pattern for extracting each stream_url, stream_quality_url and a prettified
+# stream_quality_name used for quality naming.
+#
+# Example from HTML:
+#   "2: 'http://live-ws.huomaotv.cn/live/'+stream+'_720/playlist.m3u8'"
+stream_info_pattern = re.compile(r"""
+    [1-9]:
+    \s+
+    '(?P<stream_url>(?:\w|\.|:|-|/)+)
+    '\+stream\+'
+    (?P<stream_quality_url>_?(?P<stream_quality_name>\d*))
+    /playlist.m3u8'
+""", re.VERBOSE)
+
+class Huomao(Plugin):
+    @classmethod
+    def can_handle_url(self, url):
+        return url_re.match(url)
+
+    def get_stream_id(self, html):
+        """Returns the stream_id contained in the HTML."""
+        stream_id = stream_id_pattern.search(html)
+
+        if not stream_id:
+            self.logger.error("Failed to extract stream_id.")
+
+        return stream_id.group("stream_id")
+
+    def get_stream_info(self, html):
+        """Returns a nested list of different stream options.
+
+        Each entry in the list will contain a stream_url, stream_quality_url
+        and stream_quality_name for each stream occurence that was found in
+        the JS.
+        """
+        stream_info = stream_info_pattern.findall(html)
+
+        if not stream_info:
+            self.logger.error("Failed to extract stream_info.")
+
+        # Rename the "" quality to "source" by transforming the tuples to a
+        # list and reassigning.
+        stream_info_list = []
+        for info in stream_info:
+            if not info[2]:
+                stream_info_list.append([info[0], info[1], "source"])
+            else:
+                stream_info_list.append(list(info))
+
+        return stream_info_list
+
+    def _get_streams(self):
+        room_id = url_re.search(self.url).group("room_id")
+        html = http.get(mobile_url.format(room_id))
+        stream_id = self.get_stream_id(html.text)
+        stream_info = self.get_stream_info(html.text)
+
+        streams = {}
+        for info in stream_info:
+            streams[info[2]] = HTTPStream(self.session,
+                info[0] + stream_id + info[1] + ".flv")
+
+        return streams
+
+__plugin__ = Huomao
diff --git a/src/streamlink/plugins/huya.py b/src/streamlink/plugins/huya.py
new file mode 100644
index 00000000..8c45ac97
--- /dev/null
+++ b/src/streamlink/plugins/huya.py
@@ -0,0 +1,43 @@
+import re
+
+from requests.adapters import HTTPAdapter
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import HLSStream
+from streamlink.plugin.api import useragents
+
+HUYA_URL = "http://m.huya.com/%s"
+
+_url_re = re.compile(r'http(s)?://(www\.)?huya.com/(?P<channel>[^/]+)', re.VERBOSE)
+_hls_re = re.compile(r'^\s*<source\s+src="(?P<url>[^"]+)"/>', re.MULTILINE)
+
+_hls_schema = validate.Schema(
+        validate.all(
+            validate.transform(_hls_re.search),
+            validate.any(
+                None,
+                validate.all(
+                    validate.get('url'),
+                    validate.transform(str)
+                    )
+                )
+            )
+        )
+
+class Huya(Plugin):
+    @classmethod
+    def can_handle_url(self, url):
+        return _url_re.match(url)
+
+    def _get_streams(self):
+        match = _url_re.match(self.url)
+        channel = match.group("channel")
+
+        http.headers.update({"User-Agent": useragents.IPAD})
+        #Some problem with SSL on huya.com now, do not use https
+
+        hls_url = http.get(HUYA_URL % channel, schema=_hls_schema)
+        yield "live", HLSStream(self.session, hls_url)
+
+__plugin__ = Huya
diff --git a/src/streamlink/plugins/ine.py b/src/streamlink/plugins/ine.py
index 36bffbb1..ccacd418 100644
--- a/src/streamlink/plugins/ine.py
+++ b/src/streamlink/plugins/ine.py
@@ -37,13 +37,13 @@ class INE(Plugin):
 
     def _get_streams(self):
         vid = self.url_re.match(self.url).group(1)
-        self.logger.debug("Found video ID: {}", vid)
+        self.logger.debug("Found video ID: {0}", vid)
 
         page = http.get(self.play_url.format(vid=vid))
         js_url_m = self.js_re.search(page.text)
         if js_url_m:
             js_url = js_url_m.group(1)
-            self.logger.debug("Loading player JS: {}", js_url)
+            self.logger.debug("Loading player JS: {0}", js_url)
 
             res = http.get(js_url)
             data = self.setup_schema.validate(res.text)
diff --git a/src/streamlink/plugins/media_ccc_de.py b/src/streamlink/plugins/media_ccc_de.py
index 41d10d85..9e45a9bb 100644
--- a/src/streamlink/plugins/media_ccc_de.py
+++ b/src/streamlink/plugins/media_ccc_de.py
@@ -30,16 +30,16 @@ API_URL_STREAMING_MEDIA = "https://streaming.media.ccc.de/streams/v1.json"
 
 # http(s)://media.ccc.de/path/to/talk.html
 _url_media_re = re.compile(r"(?P<scheme>http|https)"
-                           "://"
-                           "(?P<server>media\.ccc\.de)"
-                           "/")
+                           r"://"
+                           r"(?P<server>media\.ccc\.de)"
+                           r"/")
 # https://streaming.media.ccc.de/room/
 _url_streaming_media_re = re.compile(r"(?P<scheme>http|https)"
-                                     "://"
-                                     "(?P<server>streaming\.media\.ccc\.de)"
-                                     "/"
-                                     "(?P<room>.*)"
-                                     "/")
+                                     r"://"
+                                     r"(?P<server>streaming\.media\.ccc\.de)"
+                                     r"/"
+                                     r"(?P<room>.*)"
+                                     r"/")
 
 
 def get_event_id(url):
diff --git a/src/streamlink/plugins/npo.py b/src/streamlink/plugins/npo.py
index 51d5fd4e..2571b293 100644
--- a/src/streamlink/plugins/npo.py
+++ b/src/streamlink/plugins/npo.py
@@ -25,7 +25,7 @@ class NPO(Plugin):
         return _url_re.match(url)
 
     def get_token(self):
-        url = 'http://ida.omroep.nl/npoplayer/i.js?s={}'.format(quote(self.url))
+        url = 'http://ida.omroep.nl/npoplayer/i.js?s={0}'.format(quote(self.url))
         token = http.get(url, headers=HTTP_HEADERS).text
         token = re.compile(r'token.*?"(.*?)"', re.DOTALL + re.IGNORECASE).search(token).group(1)
 
@@ -53,14 +53,14 @@ class NPO(Plugin):
         return ''.join(secured)
 
     def _get_meta(self):
-        html = http.get('http://www.npo.nl/live/{}'.format(self.npo_id), headers=HTTP_HEADERS).text
+        html = http.get('http://www.npo.nl/live/{0}'.format(self.npo_id), headers=HTTP_HEADERS).text
         program_id = re.compile(r'data-prid="(.*?)"', re.DOTALL + re.IGNORECASE).search(html).group(1)
-        meta = http.get('http://e.omroep.nl/metadata/{}'.format(program_id), headers=HTTP_HEADERS).text
+        meta = http.get('http://e.omroep.nl/metadata/{0}'.format(program_id), headers=HTTP_HEADERS).text
         meta = re.compile(r'({.*})', re.DOTALL + re.IGNORECASE).search(meta).group(1)
         return json.loads(meta)
 
     def _get_vod_streams(self):
-        url = 'http://ida.omroep.nl/odi/?prid={}&puboptions=adaptive,h264_bb,h264_sb,h264_std&adaptive=no&part=1&token={}'\
+        url = 'http://ida.omroep.nl/odi/?prid={0}&puboptions=adaptive,h264_bb,h264_sb,h264_std&adaptive=no&part=1&token={1}'\
             .format(quote(self.npo_id), quote(self.get_token()))
         res = http.get(url, headers=HTTP_HEADERS)
 
@@ -75,7 +75,7 @@ class NPO(Plugin):
         meta = self._get_meta()
         stream = [x for x in meta['streams'] if x['type'] == 'hls'][0]['url']
 
-        url = 'http://ida.omroep.nl/aapi/?type=jsonp&stream={}&token={}'.format(stream, self.get_token())
+        url = 'http://ida.omroep.nl/aapi/?type=jsonp&stream={0}&token={1}'.format(stream, self.get_token())
         streamdata = http.get(url, headers=HTTP_HEADERS).json()
         deeplink = http.get(streamdata['stream'], headers=HTTP_HEADERS).text
         deeplink = re.compile(r'"(.*?)"', re.DOTALL + re.IGNORECASE).search(deeplink).group(1)
diff --git a/src/streamlink/plugins/nrk.py b/src/streamlink/plugins/nrk.py
index f3fc0d99..ed97fa49 100644
--- a/src/streamlink/plugins/nrk.py
+++ b/src/streamlink/plugins/nrk.py
@@ -53,7 +53,7 @@ class NRK(Plugin):
         program_id = _id_re.search(self.url).group(1)
 
         # Extract media URL.
-        json_url = urljoin(baseurl, "mediaelement/{}".format(program_id))
+        json_url = urljoin(baseurl, "mediaelement/{0}".format(program_id))
         res = http.get(json_url, cookies=cookie)
         media_element = http.json(res, schema=_mediaelement_schema)
         media_url = media_element["mediaUrl"]
diff --git a/src/streamlink/plugins/openrectv.py b/src/streamlink/plugins/openrectv.py
index 0e358037..ecebc230 100644
--- a/src/streamlink/plugins/openrectv.py
+++ b/src/streamlink/plugins/openrectv.py
@@ -3,19 +3,29 @@ import re
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
 from streamlink.stream import HLSStream
-
-_url_re = re.compile(r"http(s)?://(www\.)?openrec.tv/(live|movie)/[^/?&]+")
-_playlist_url_re = re.compile(r"data-file=\"(?P<url>[^\"]+)\"")
-_schema = validate.Schema(
-    validate.transform(_playlist_url_re.search),
+from streamlink.utils import parse_json
+
+_url_re = re.compile(r"https?://(?:www\.)?openrec.tv/(live|movie)/")
+_playlist_url_re = re.compile(r"data-(source)?file=\"(?P<url>[^\"]+)\"")
+_movie_data_re = re.compile(r'''<script type="application/ld\+json">(.*?)</script>''', re.DOTALL | re.M)
+_live_schema = validate.Schema(
+    validate.transform(_playlist_url_re.findall),
+    [
+        validate.union({
+            "isSource": validate.all(validate.get(0), validate.transform(lambda s: s == "source")),
+            "url": validate.all(validate.get(1), validate.url(scheme="http",
+                                                              path=validate.endswith(".m3u8")))
+        })
+    ]
+)
+_movie_schema = validate.Schema(
+    validate.transform(_movie_data_re.search),
     validate.any(
         None,
         validate.all(
-            validate.get("url"),
-            validate.url(
-                scheme="http",
-                path=validate.endswith(".m3u8")
-            )
+            validate.get(1),
+            validate.transform(parse_json),
+            validate.get("contentUrl")
         )
     )
 )
@@ -23,15 +33,29 @@ _schema = validate.Schema(
 
 class OPENRECtv(Plugin):
     @classmethod
-    def can_handle_url(self, url):
-        return _url_re.match(url)
+    def can_handle_url(cls, url):
+        return _url_re.match(url) is not None
 
-    def _get_streams(self):
-        playlist_url = http.get(self.url, schema=_schema)
-        if not playlist_url:
-            return
+    @classmethod
+    def stream_weight(cls, stream):
+        if stream == "source":
+            return 1080 + 1, "openrectv"
+        return Plugin.stream_weight(stream)
 
-        return HLSStream.parse_variant_playlist(self.session, playlist_url)
+    def _get_streams(self):
+        stype = _url_re.match(self.url).group(1)
+        if stype.lower() == "live":
+            self.logger.debug("Searching the page for live stream URLs")
+            playlists = http.get(self.url, schema=_live_schema)
+            for playlist in playlists:
+                for q, s in HLSStream.parse_variant_playlist(self.session, playlist["url"]).items():
+                    yield "source" if playlist["isSource"] else q, s
+        elif stype.lower() == "movie":
+            self.logger.debug("Searching the page for VOD stream URLs")
+            playlist = http.get(self.url, schema=_movie_schema)
+            if playlist:
+                for s in HLSStream.parse_variant_playlist(self.session, playlist).items():
+                    yield s
 
 
 __plugin__ = OPENRECtv
diff --git a/src/streamlink/plugins/orf_tvthek.py b/src/streamlink/plugins/orf_tvthek.py
index a533ab7e..0baaee5d 100644
--- a/src/streamlink/plugins/orf_tvthek.py
+++ b/src/streamlink/plugins/orf_tvthek.py
@@ -43,7 +43,7 @@ class ORFTVThek(Plugin):
             try:
                 if source['delivery'] != 'hls':
                     continue
-                url = source['src'].replace('\/', '/')
+                url = source['src'].replace(r'\/', '/')
             except KeyError:
                 continue
             stream = HLSStream.parse_variant_playlist(self.session, url)
diff --git a/src/streamlink/plugins/pandatv.py b/src/streamlink/plugins/pandatv.py
index d5318621..324b1e76 100644
--- a/src/streamlink/plugins/pandatv.py
+++ b/src/streamlink/plugins/pandatv.py
@@ -2,17 +2,26 @@
 
 import re
 import types
+import time
+import json
+
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
 from streamlink.stream import HTTPStream
 
-ROOM_API = "http://www.panda.tv/api_room?roomid="
-SD_URL_PATTERN = "http://pl{0}.live.panda.tv/live_panda/{1}.flv"
-HD_URL_PATTERN = "http://pl{0}.live.panda.tv/live_panda/{1}_mid.flv"
-# I don't know ordinary-definition url pattern, sorry for ignore it.
-OD_URL_PATTERN = "http://pl{0}.live.panda.tv/live_panda/{1}_mid.flv"
+ROOM_API = "http://www.panda.tv/api_room_v3?roomid={0}&roomkey={1}&_={2}"
+ROOM_API_V2 = "http://www.panda.tv/api_room_v2?roomid={0}&_={1}"
+SD_URL_PATTERN = "http://pl{0}.live.panda.tv/live_panda/{1}.flv?sign={2}&ts={3}&rid={4}"
+HD_URL_PATTERN = "http://pl{0}.live.panda.tv/live_panda/{1}_mid.flv?sign={2}&ts={3}&rid={4}"
+OD_URL_PATTERN = "http://pl{0}.live.panda.tv/live_panda/{1}_small.flv?sign={2}&ts={3}&rid={4}"
 
 _url_re = re.compile(r"http(s)?://(\w+.)?panda.tv/(?P<channel>[^/&?]+)")
+_room_id_re = re.compile(r'data-room-id="(\d+)"')
+_status_re = re.compile(r'"status"\s*:\s*"(\d+)"\s*,\s*"display_type"')
+_room_key_re = re.compile(r'"room_key"\s*:\s*"(.+?)"')
+_sd_re = re.compile(r'"SD"\s*:\s*"(\d+)"')
+_hd_re = re.compile(r'"HD"\s*:\s*"(\d+)"')
+_od_re = re.compile(r'"OD"\s*:\s*"(\d+)"')
 
 _room_schema = validate.Schema(
     {
@@ -23,14 +32,8 @@ _room_schema = validate.Schema(
                 "videoinfo": validate.any(
                     validate.text,
                     {
-                        "room_key": validate.text,
-                        "plflag": validate.text,
-                        "status": validate.text,
-                        "stream_addr": {
-                            "HD": validate.text,
-                            "OD": validate.text,
-                            "SD": validate.text
-                        }
+                        "plflag_list": validate.text,
+                        "plflag": validate.text
                     }
                 )
             }
@@ -39,7 +42,7 @@ _room_schema = validate.Schema(
     validate.get("data"))
 
 
-class pandatv(Plugin):
+class Pandatv(Plugin):
     @classmethod
     def can_handle_url(self, url):
         return _url_re.match(url)
@@ -48,42 +51,71 @@ class pandatv(Plugin):
         match = _url_re.match(self.url)
         channel = match.group("channel")
 
-        url = ROOM_API + channel
-        res = http.get(url)
-        data = http.json(res, schema=_room_schema)
-        if not isinstance(data, dict):
-            self.logger.error("Please Check PandaTV Room API")
+        res = http.get(self.url)
+
+        try:
+            channel = int(channel)
+        except ValueError:
+            channel = _room_id_re.search(res.text).group(1)
+            ts = int(time.time())
+            url = ROOM_API_V2.format(channel, ts)
+            res = http.get(url)
+
+        try:
+            status = _status_re.search(res.text).group(1)
+            room_key = _room_key_re.search(res.text).group(1)
+            sd = _sd_re.search(res.text).group(1)
+            hd = _hd_re.search(res.text).group(1)
+            od = _od_re.search(res.text).group(1)
+        except AttributeError:
+            self.logger.info("Not a valid room url.")
             return
 
-        videoinfo = data.get('videoinfo')
+        if status != '2':
+            self.logger.info("Stream currently unavailable.")
+            return
 
-        if not videoinfo or not videoinfo.get('status'):
-            self.logger.error("Please Check PandaTV Room API")
+        ts = int(time.time())
+        url = ROOM_API.format(channel, room_key, ts)
+        room = http.get(url)
+        data = http.json(room, schema=_room_schema)
+        if not isinstance(data, dict):
+            self.logger.info("Please Check PandaTV Room API")
             return
 
-        if videoinfo.get('status') != '2':
-            self.logger.info("Channel offline now!")
+        videoinfo = data.get('videoinfo')
+        plflag_list = videoinfo.get('plflag_list')
+        if not videoinfo or not plflag_list:
+            self.logger.info("Please Check PandaTV Room API")
             return
 
         streams = {}
         plflag = videoinfo.get('plflag')
-        if plflag or not '_' not in plflag:
-            self.logger.error("Please Check PandaTV Room API")
+        if not plflag or '_' not in plflag:
+            self.logger.info("Please Check PandaTV Room API")
             return
-        plflag = plflag.split('_')[1]
-        room_key = videoinfo.get('room_key')
 
-        # SD(Super high Definition) has higher quality than HD(High Definition) which
-        # conflict with existing code, use ehq and hq instead.
-        stream_addr = videoinfo.get('stream_addr')
+        plflag0 = plflag.split('_')[1]
+        if plflag0 != '3':
+            plflag1 = '4'
+        else:
+            plflag1 = '3'
+
+        plflag_list = json.loads(plflag_list)
+        rid = plflag_list["auth"]["rid"]
+        sign = plflag_list["auth"]["sign"]
+        ts = plflag_list["auth"]["time"]
+
+        if sd == '1':
+            streams['ehq'] = HTTPStream(self.session, SD_URL_PATTERN.format(plflag1, room_key, sign, ts, rid))
 
-        if stream_addr and stream_addr.get('SD') == '1':
-            streams['ehq'] = HTTPStream(self.session, SD_URL_PATTERN.format(plflag, room_key))
+        if hd == '1':
+            streams['hq'] = HTTPStream(self.session, HD_URL_PATTERN.format(plflag1, room_key, sign, ts, rid))
 
-        if stream_addr and stream_addr.get('HD') == '1':
-            streams['hq'] = HTTPStream(self.session, HD_URL_PATTERN.format(plflag, room_key))
+        if od == '1':
+            streams['sq'] = HTTPStream(self.session, OD_URL_PATTERN.format(plflag1, room_key, sign, ts, rid))
 
         return streams
 
 
-__plugin__ = pandatv
+__plugin__ = Pandatv
diff --git a/src/streamlink/plugins/pluzz.py b/src/streamlink/plugins/pluzz.py
index 52899872..c4ef67c5 100644
--- a/src/streamlink/plugins/pluzz.py
+++ b/src/streamlink/plugins/pluzz.py
@@ -36,6 +36,7 @@ class Pluzz(Plugin):
                     validate.url(),
                 ),
                 'statut': validate.text,
+                'drm': bool,
                 'geoblocage': validate.any(
                     None,
                     [validate.all(validate.text)]
@@ -87,19 +88,26 @@ class Pluzz(Plugin):
         videos = http.json(res, schema=self._api_schema)
         now = time.time()
 
+        offline = False
+        geolocked = False
+        drm = False
+        expired = False
         for video in videos['videos']:
             video_url = video['url']
 
-            # TODO: add DASH streams once supported
-            if '.mpd' in video_url:
-                continue
-
             # Check whether video format is available
             if video['statut'] != 'ONLINE':
+                offline = offline or True
                 continue
 
             # Check whether video format is geo-locked
             if video['geoblocage'] is not None and country_code not in video['geoblocage']:
+                geolocked = geolocked or True
+                continue
+
+            # Check whether video is DRM-protected
+            if video['drm']:
+                drm = drm or True
                 continue
 
             # Check whether video format is expired
@@ -109,6 +117,11 @@ class Pluzz(Plugin):
                 if available:
                     break
             if not available:
+                expired = expired or True
+                continue
+
+            # TODO: add DASH streams once supported
+            if '.mpd' in video_url:
                 continue
 
             res = http.get(self.TOKEN_URL.format(video_url))
@@ -128,5 +141,14 @@ class Pluzz(Plugin):
                 for stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
                     yield stream
 
+        if offline:
+            self.logger.error('Failed to access stream, may be due to offline content')
+        if geolocked:
+            self.logger.error('Failed to access stream, may be due to geo-restricted content')
+        if drm:
+            self.logger.error('Failed to access stream, may be due to DRM-protected content')
+        if expired:
+            self.logger.error('Failed to access stream, may be due to expired content')
+
 
 __plugin__ = Pluzz
diff --git a/src/streamlink/plugins/powerapp.py b/src/streamlink/plugins/powerapp.py
index 0abe691e..832bb5ef 100644
--- a/src/streamlink/plugins/powerapp.py
+++ b/src/streamlink/plugins/powerapp.py
@@ -9,7 +9,7 @@ from streamlink.stream import HLSStream
 
 class PowerApp(Plugin):
     url_re = re.compile(r"https?://(?:www.)?powerapp.com.tr/tv/(\w+)")
-    api_url = "http://api.powergroup.com.tr/Channels/{}/?appRef=iPowerWeb&apiVersion=11"
+    api_url = "http://api.powergroup.com.tr/Channels/{0}/?appRef=iPowerWeb&apiVersion=11"
     api_schema = validate.Schema(validate.all({
         "errorCode": 0,
         "response": {
diff --git a/src/streamlink/plugins/rtvs.py b/src/streamlink/plugins/rtvs.py
new file mode 100644
index 00000000..b2e03c09
--- /dev/null
+++ b/src/streamlink/plugins/rtvs.py
@@ -0,0 +1,66 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import RTMPStream, HLSStream
+
+RUURL = "b=chrome&p=win&v=56&f=0&d=1"
+
+_url_re = re.compile(r"https?://www.rtvs.sk/televizia/live-[\w-]+")
+_playlist_url_re = re.compile(r'"playlist": "([^"]+)"')
+
+_playlist_schema = validate.Schema(
+    [
+        {
+            "sources": [
+                validate.any(
+                    {
+                        "type": "dash",
+                        "file": validate.url(scheme="http")
+                    }, {
+                        "type": "hls",
+                        "file": validate.url(scheme="http")
+                    }, {
+                        "type": "rtmp",
+                        "file": validate.text,
+                        "streamer": validate.url(scheme="rtmp")
+                    }
+                )
+            ]
+        }
+    ],
+    validate.get(0),
+    validate.get("sources")
+)
+
+
+class Rtvs(Plugin):
+    @classmethod
+    def can_handle_url(cls, url):
+        return _url_re.match(url)
+
+    def _get_streams(self):
+        res = http.get(self.url)
+        match = _playlist_url_re.search(res.text)
+        if match is None:
+            return
+
+        res = http.get(match.group(1) + RUURL)
+        sources = http.json(res, schema=_playlist_schema)
+
+        streams = {}
+
+        for source in sources:
+            if source["type"] == "rtmp":
+                streams["rtmp_live"] = RTMPStream(self.session, {
+                    "rtmp": source["streamer"],
+                    "pageUrl": self.url,
+                    "live": True
+                })
+            elif source["type"] == "hls":
+                streams.update(HLSStream.parse_variant_playlist(self.session, source["file"]))
+
+        return streams
+
+
+__plugin__ = Rtvs
diff --git a/src/streamlink/plugins/streamme.py b/src/streamlink/plugins/streamme.py
index fedec207..c01b9938 100644
--- a/src/streamlink/plugins/streamme.py
+++ b/src/streamlink/plugins/streamme.py
@@ -15,7 +15,7 @@ class StreamMe(Plugin):
 
     def _get_streams(self):
         username = _RE_URL.match(self.url).group(1)
-        url = 'https://www.stream.me/api-user/v1/{}/channel'.format(username)
+        url = 'https://www.stream.me/api-user/v1/{0}/channel'.format(username)
         data = http.get(url).json()
         try:
             m3u8 = data['_embedded']['streams'][0]['_links']['hlsmp4']['href']
diff --git a/src/streamlink/plugins/tigerdile.py b/src/streamlink/plugins/tigerdile.py
index 3940ab83..6c3c967c 100644
--- a/src/streamlink/plugins/tigerdile.py
+++ b/src/streamlink/plugins/tigerdile.py
@@ -5,7 +5,7 @@ from streamlink.plugin.api import http, validate
 from streamlink.stream import RTMPStream
 
 PAGE_URL = "https://www.tigerdile.com/stream/"
-ROOT_URL = "rtmp://stream.tigerdile.com/live/{}"
+ROOT_URL = "rtmp://stream.tigerdile.com/live/{0}"
 STREAM_TYPES = ["rtmp"]
 
 _url_re = re.compile(r"""
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 8f916bca..47cc583a 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -1,10 +1,9 @@
 # coding=utf-8
 import re
-
-import requests
-
+import warnings
 from random import random
 
+import requests
 from streamlink.compat import urlparse
 from streamlink.exceptions import NoStreamsError, PluginError, StreamError
 from streamlink.plugin import Plugin, PluginOptions
@@ -40,9 +39,11 @@ _url_re = re.compile(r"""
         (?P<subdomain>[\w\-]+)
         \.
     )?
-    twitch.tv
-    /
-    (?P<channel>[^/]+)
+    twitch.tv/
+    (?:
+        videos/(?P<videos_id>\d+)|
+        (?P<channel>[^/]+)
+    )
     (?:
         /
         (?P<video_type>[bcv])
@@ -180,9 +181,10 @@ class UsherService(object):
 
 
 class TwitchAPI(object):
-    def __init__(self, beta=False):
+    def __init__(self, beta=False, version=3):
         self.oauth_token = None
         self.subdomain = beta and "betaapi" or "api"
+        self.version = version
 
     def add_cookies(self, cookies):
         http.parse_cookies(cookies, domain="twitch.tv")
@@ -198,7 +200,7 @@ class TwitchAPI(object):
         else:
             url = "https://{0}.twitch.tv{1}".format(self.subdomain, path)
 
-        headers = {'Accept': 'application/vnd.twitchtv.v3+json',
+        headers = {'Accept': 'application/vnd.twitchtv.v{0}+json'.format(self.version),
                    'Client-ID': TWITCH_CLIENT_ID}
 
         # The certificate used by Twitch cannot be verified on some OpenSSL versions.
@@ -216,27 +218,28 @@ class TwitchAPI(object):
         self.subdomain = subdomain_buffer
         return response
 
-    def access_token(self, endpoint, asset, **params):
-        return self.call("/api/{0}/{1}/access_token".format(endpoint, asset), **params)
+    # Public API calls
+
+    def user(self, **params):
+        return self.call("/kraken/user", **params)
+
+    def users(self, **params):
+        return self.call("/kraken/users", **params)
+
+    def videos(self, video_id, **params):
+        return self.call("/kraken/videos/{0}".format(video_id), **params)
 
     def channel_info(self, channel, **params):
         return self.call("/kraken/channels/{0}".format(channel), **params)
 
-    def channel_subscription(self, channel, **params):
-        return self.call("/api/channels/{0}/subscription".format(channel), **params)
+    # Private API calls
 
-    def channel_viewer_info(self, channel, **params):
-        return self.call("/api/channels/{0}/viewer".format(channel), **params)
+    def access_token(self, endpoint, asset, **params):
+        return self.call("/api/{0}/{1}/access_token".format(endpoint, asset), **params)
 
     def token(self, **params):
         return self.call("/api/viewer/token", **params)
 
-    def user(self, **params):
-        return self.call("/kraken/user", **params)
-
-    def videos(self, video_id, **params):
-        return self.call("/kraken/videos/{0}".format(video_id), **params)
-
     def viewer_info(self, **params):
         return self.call("/api/viewer/info", **params)
 
@@ -246,6 +249,17 @@ class TwitchAPI(object):
     def clip_status(self, channel, clip_name, schema):
         return http.json(self.call_subdomain("clips", "/api/v1/clips/" + channel + "/" + clip_name + "/status", format=""), schema=schema)
 
+    # Unsupported/Removed private API calls
+
+    def channel_viewer_info(self, channel, **params):
+        warnings.warn("The channel_viewer_info API call is unsupported and may stop working at any time")
+        return self.call("/api/channels/{0}/viewer".format(channel), **params)
+
+    def channel_subscription(self, channel, **params):
+        warnings.warn("The channel_subscription API call has been removed and no longer works",
+                      category=DeprecationWarning)
+        return self.call("/api/channels/{0}/subscription".format(channel), **params)
+
 
 class Twitch(Plugin):
     options = PluginOptions({
@@ -269,19 +283,66 @@ class Twitch(Plugin):
     def __init__(self, url):
         Plugin.__init__(self, url)
         match = _url_re.match(url).groupdict()
-        self.channel = match.get("channel").lower()
+        self._channel = match.get("channel") and match.get("channel").lower()
+        self._channel_id = None
         self.subdomain = match.get("subdomain")
         self.video_type = match.get("video_type")
-        self.video_id = match.get("video_id")
+        if match.get("videos_id"):
+            self.video_type = "v"
+        self.video_id = match.get("video_id") or match.get("videos_id")
         self.clip_name = match.get("clip_name")
         self._hosted_chain = []
 
         parsed = urlparse(url)
         self.params = parse_query(parsed.query)
 
-        self.api = TwitchAPI(beta=self.subdomain == "beta")
+        self.api = TwitchAPI(beta=self.subdomain == "beta", version=5)
         self.usher = UsherService()
 
+    @property
+    def channel(self):
+        if not self._channel:
+            if self.video_id:
+                cdata = self._channel_from_video_id(self.video_id)
+                self._channel = cdata["name"].lower()
+                self._channel_id = cdata["_id"]
+        return self._channel
+
+    @channel.setter
+    def channel(self, channel):
+        self._channel = channel
+        # channel id becomes unknown
+        self._channel_id = None
+
+    @property
+    def channel_id(self):
+        if not self._channel_id:
+            # If the channel name is set, use that to look up the ID
+            if self._channel:
+                cdata = self._channel_from_login(self._channel)
+                self._channel_id = cdata["_id"]
+
+            # If the channel name is not set but the video ID is,
+            # use that to look up both ID and name
+            elif self.video_id:
+                cdata = self._channel_from_video_id(self.video_id)
+                self._channel = cdata["name"].lower()
+                self._channel_id = cdata["_id"]
+        return self._channel_id
+
+    def _channel_from_video_id(self, video_id):
+        vdata = self.api.videos(video_id)
+        if "channel" not in vdata:
+            raise PluginError("Unable to find video: {0}".format(video_id))
+        return vdata["channel"]
+
+    def _channel_from_login(self, channel):
+        cdata = self.api.users(login=channel)
+        if len(cdata["users"]):
+            return cdata["users"][0]
+        else:
+            raise PluginError("Unable to find channel: {0}".format(channel))
+
     def _authenticate(self):
         if self.api.oauth_token:
             return
@@ -374,7 +435,7 @@ class Twitch(Plugin):
             chunk_stop = chunk_start + chunk_length
             chunk_stream = HTTPStream(self.session, chunk_url)
 
-            if start_offset >= chunk_start and start_offset <= chunk_stop:
+            if chunk_start <= start_offset <= chunk_stop:
                 try:
                     headers = extract_flv_header_tags(chunk_stream)
                 except IOError as err:
@@ -411,7 +472,7 @@ class Twitch(Plugin):
                 playlist_streams.append(chunk_stream)
                 for tag in headers:
                     playlist_tags.append(tag)
-            elif chunk_start >= start_offset and chunk_start < stop_offset:
+            elif start_offset <= chunk_start < stop_offset:
                 playlist_streams.append(chunk_stream)
 
             playlist_offset += chunk_length
@@ -420,7 +481,7 @@ class Twitch(Plugin):
                            tags=playlist_tags, duration=playlist_duration)
 
     def _get_video_streams(self):
-        self.logger.debug("Getting video steams for {} (type={})".format(self.video_id, self.video_type))
+        self.logger.debug("Getting video steams for {0} (type={1})".format(self.video_id, self.video_type))
         self._authenticate()
 
         if self.video_type == "b":
@@ -463,36 +524,37 @@ class Twitch(Plugin):
         return sig, token
 
     def _check_for_host(self):
-        channel_id = self.api.channel_info(self.channel)["_id"]
-        host_info = self.api.hosted_channel(include_logins=1, host=channel_id).json()["hosts"][0]
-        if "target_login" in host_info:
+        host_info = self.api.hosted_channel(include_logins=1, host=self.channel_id).json()["hosts"][0]
+        if "target_login" in host_info and host_info["target_login"].lower() != self.channel.lower():
             self.logger.info("{0} is hosting {1}".format(self.channel, host_info["target_login"]))
             return host_info["target_login"]
 
     def _get_hls_streams(self, stream_type="live"):
-        self.logger.debug("Getting {} HLS streams for {}".format(stream_type, self.channel))
+        self.logger.debug("Getting {0} HLS streams for {1}".format(stream_type, self.channel))
         self._authenticate()
-        sig, token = self._access_token(stream_type)
+        self._hosted_chain.append(self.channel)
+
         if stream_type == "live":
             hosted_channel = self._check_for_host()
             if hosted_channel and self.options.get("disable_hosting"):
                 self.logger.info("hosting was disabled by command line option")
-                return {}
             elif hosted_channel:
-                self.logger.info("switching to {}", hosted_channel)
+                self.logger.info("switching to {0}", hosted_channel)
                 if hosted_channel in self._hosted_chain:
-                    self._hosted_chain.append(hosted_channel)
                     self.logger.error(u"A loop of hosted channels has been detected, "
-                                      "cannot find a playable stream. ({})".format(u" -> ".join(self._hosted_chain)))
+                                      "cannot find a playable stream. ({0})".format(u" -> ".join(self._hosted_chain + [hosted_channel])))
                     return {}
-                self._hosted_chain.append(hosted_channel)
                 self.channel = hosted_channel
                 return self._get_hls_streams(stream_type)
+
+            # only get the token once the channel has been resolved
+            sig, token = self._access_token(stream_type)
             url = self.usher.channel(self.channel, sig=sig, token=token)
         elif stream_type == "video":
+            sig, token = self._access_token(stream_type)
             url = self.usher.video(self.video_id, nauthsig=sig, nauth=token)
         else:
-            self.logger.debug("Unknown HLS stream type: {}".format(stream_type))
+            self.logger.debug("Unknown HLS stream type: {0}".format(stream_type))
             return {}
 
         try:
diff --git a/src/streamlink/session.py b/src/streamlink/session.py
index 3caf20c6..ceb4461e 100644
--- a/src/streamlink/session.py
+++ b/src/streamlink/session.py
@@ -53,6 +53,7 @@ class Streamlink(object):
             "hls-segment-threads": 1,
             "hls-segment-timeout": 10.0,
             "hls-timeout": 60.0,
+            "hls-playlist-reload-attempts": 3,
             "http-stream-timeout": 60.0,
             "ringbuffer-size": 1024 * 1024 * 16,  # 16 MB
             "rtmp-timeout": 60.0,
diff --git a/src/streamlink/stream/__init__.py b/src/streamlink/stream/__init__.py
index 8502d625..d4498490 100644
--- a/src/streamlink/stream/__init__.py
+++ b/src/streamlink/stream/__init__.py
@@ -1,13 +1,13 @@
 from ..exceptions import StreamError
-from .stream import Stream
+from streamlink.stream.stream import Stream
 
-from .akamaihd import AkamaiHDStream
-from .hds import HDSStream
-from .hls import HLSStream
-from .http import HTTPStream
-from .rtmpdump import RTMPStream
-from .streamprocess import StreamProcess
-from .wrappers import StreamIOWrapper, StreamIOIterWrapper, StreamIOThreadWrapper
+from streamlink.stream.akamaihd import AkamaiHDStream
+from streamlink.stream.hds import HDSStream
+from streamlink.stream.hls import HLSStream
+from streamlink.stream.http import HTTPStream
+from streamlink.stream.rtmpdump import RTMPStream
+from streamlink.stream.streamprocess import StreamProcess
+from streamlink.stream.wrappers import StreamIOWrapper, StreamIOIterWrapper, StreamIOThreadWrapper
 
-from .flvconcat import extract_flv_header_tags
-from .playlist import Playlist, FLVPlaylist
+from streamlink.stream.flvconcat import extract_flv_header_tags
+from streamlink.stream.playlist import Playlist, FLVPlaylist
diff --git a/src/streamlink/stream/ffmpegmux.py b/src/streamlink/stream/ffmpegmux.py
index b6db0088..c1c09c6f 100644
--- a/src/streamlink/stream/ffmpegmux.py
+++ b/src/streamlink/stream/ffmpegmux.py
@@ -5,6 +5,8 @@ import threading
 import subprocess
 
 import sys
+
+from streamlink import StreamError
 from streamlink.stream import Stream
 from streamlink.stream.stream import StreamIO
 from streamlink.utils import NamedPipe
@@ -34,7 +36,7 @@ class FFMPEGMuxer(StreamIO):
 
     @staticmethod
     def copy_to_pipe(self, stream, pipe):
-        self.logger.debug("Starting copy to pipe: {}".format(pipe.path))
+        self.logger.debug("Starting copy to pipe: {0}".format(pipe.path))
         pipe.open("wb")
         while not stream.closed:
             try:
@@ -44,13 +46,13 @@ class FFMPEGMuxer(StreamIO):
                 else:
                     break
             except IOError:
-                self.logger.error("Pipe copy aborted: {}".format(pipe.path))
+                self.logger.error("Pipe copy aborted: {0}".format(pipe.path))
                 return
         try:
             pipe.close()
         except IOError:  # might fail closing, but that should be ok for the pipe
             pass
-        self.logger.debug("Pipe copy complete: {}".format(pipe.path))
+        self.logger.debug("Pipe copy complete: {0}".format(pipe.path))
 
     def __init__(self, session, *streams, **options):
         if not self.is_usable(session):
@@ -61,7 +63,7 @@ class FFMPEGMuxer(StreamIO):
         self.logger = session.logger.new_module("stream.mp4mux-ffmpeg")
         self.streams = streams
 
-        self.pipes = [NamedPipe("foo-{}-{}".format(os.getpid(), random.randint(0, 1000))) for _ in self.streams]
+        self.pipes = [NamedPipe("ffmpeg-{0}-{1}".format(os.getpid(), random.randint(0, 1000))) for _ in self.streams]
         self.pipe_threads = [threading.Thread(target=self.copy_to_pipe, args=(self, stream, np))
                              for stream, np in
                              zip(self.streams, self.pipes)]
@@ -84,7 +86,7 @@ class FFMPEGMuxer(StreamIO):
                 self._cmd.extend(["-metadata:{0}".format(stream), datum])
 
         self._cmd.extend(['-f', ofmt, outpath])
-        self.logger.debug("ffmpeg command: {}".format(' '.join(self._cmd)))
+        self.logger.debug("ffmpeg command: {0}".format(' '.join(self._cmd)))
         self.close_errorlog = False
 
         if session.options.get("ffmpeg-verbose"):
diff --git a/src/streamlink/stream/hds.py b/src/streamlink/stream/hds.py
index 5d433e26..45910bd5 100644
--- a/src/streamlink/stream/hds.py
+++ b/src/streamlink/stream/hds.py
@@ -23,7 +23,7 @@ from .wrappers import StreamIOIterWrapper
 
 from ..cache import Cache
 from ..compat import parse_qsl, urljoin, urlparse, urlunparse, bytes, range
-from ..exceptions import StreamError
+from ..exceptions import StreamError, PluginError
 from ..utils import absolute_url, swfdecompress
 
 from ..packages.flashmedia import F4V, F4VError
@@ -431,9 +431,12 @@ class HDSStream(Stream):
         :param url: The URL to the manifest.
         :param timeout: How long to wait for data to be returned from
                         from the stream before raising an error.
+        :param is_akamai: force adding of the akamai parameters
         :param pvswf: URL of player SWF for Akamai HD player verification.
         """
-        logger = session.logger.new_module("hls.parse_manifest")
+        logger = session.logger.new_module("hds.parse_manifest")
+        # private argument, should only be used in recursive calls
+        raise_for_drm = request_params.pop("raise_for_drm", False)
 
         if not request_params:
             request_params = {}
@@ -457,6 +460,9 @@ class HDSStream(Stream):
 
         if manifest.findtext("drmAdditionalHeader"):
             logger.debug("Omitting HDS stream protected by DRM: {}", url)
+            if raise_for_drm:
+                raise PluginError("{} is protected by DRM".format(url))
+            logger.warning("Some or all streams are unavailable as they are protected by DRM")
             return {}
 
         parsed = urlparse(url)
@@ -492,6 +498,8 @@ class HDSStream(Stream):
             params = cls._pv_params(session, pvswf, pvtoken, **request_params)
             request_params["params"].update(params)
 
+        child_drm = False
+
         for media in manifest.findall("media"):
             url = media.attrib.get("url")
             bootstrapid = media.attrib.get("bootstrapInfoId", "_global")
@@ -533,9 +541,15 @@ class HDSStream(Stream):
 
             elif href:
                 url = absolute_url(baseurl, href)
-                child_streams = cls.parse_manifest(session, url,
-                                                   timeout=timeout,
-                                                   **request_params)
+                try:
+                    child_streams = cls.parse_manifest(session, url,
+                                                       timeout=timeout,
+                                                       is_akamai=is_akamai,
+                                                       raise_for_drm=True,
+                                                       **request_params)
+                except PluginError:
+                    child_drm = True
+                    child_streams = {}
 
                 for name, stream in child_streams.items():
                     # Override stream name if bitrate is available in parent
@@ -546,6 +560,8 @@ class HDSStream(Stream):
                         name = bitrate + "k"
 
                     streams[name] = stream
+        if child_drm:
+            logger.warning("Some or all streams are unavailable as they are protected by DRM")
 
         return streams
 
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
index 9e260537..9cfcafa7 100644
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -1,31 +1,23 @@
+import struct
 from collections import defaultdict, namedtuple
 
-from streamlink.stream.ffmpegmux import FFMPEGMuxer, MuxedStream
-from streamlink.utils.l10n import Localization
-
-try:
-    from Crypto.Cipher import AES
-    import struct
-
-
-    def num_to_iv(n):
-        return struct.pack(">8xq", n)
-
-
-    CAN_DECRYPT = True
-except ImportError:
-    CAN_DECRYPT = False
+from Crypto.Cipher import AES
 
-from . import hls_playlist
-from .http import HTTPStream
-from .segmented import (SegmentedStreamReader,
-                        SegmentedStreamWriter,
-                        SegmentedStreamWorker)
+from streamlink.stream import hls_playlist
+from streamlink.stream.ffmpegmux import FFMPEGMuxer, MuxedStream
+from streamlink.stream.http import HTTPStream
+from streamlink.stream.segmented import (SegmentedStreamReader,
+                                         SegmentedStreamWriter,
+                                         SegmentedStreamWorker)
 from ..exceptions import StreamError
 
 Sequence = namedtuple("Sequence", "num segment")
 
 
+def num_to_iv(n):
+    return struct.pack(">8xq", n)
+
+
 class HLSStreamWriter(SegmentedStreamWriter):
     def __init__(self, reader, *args, **kwargs):
         options = reader.stream.session.options
@@ -47,6 +39,7 @@ class HLSStreamWriter(SegmentedStreamWriter):
 
         if self.key_uri != key.uri:
             res = self.session.http.get(key.uri, exception=StreamError,
+                                        retries=self.retries,
                                         **self.reader.request_params)
             self.key_data = res.content
             self.key_uri = key.uri
@@ -83,48 +76,37 @@ class HLSStreamWriter(SegmentedStreamWriter):
         try:
             request_params = self.create_request_params(sequence)
             return self.session.http.get(sequence.segment.uri,
-                                         stream=True,
                                          timeout=self.timeout,
                                          exception=StreamError,
+                                         retries=self.retries,
                                          **request_params)
         except StreamError as err:
             self.logger.error("Failed to open segment {0}: {1}", sequence.num, err)
-            return self.fetch(sequence, retries - 1)
-
-    def write(self, sequence, res, chunk_size=8192, retries=None):
-        retries = retries or self.retries
-        if retries == 0:
-            self.logger.error("Failed to open segment {0}", sequence.num)
             return
-        try:
-            if sequence.segment.key and sequence.segment.key.method != "NONE":
-                try:
-                    decryptor = self.create_decryptor(sequence.segment.key,
-                                                      sequence.num)
-                except StreamError as err:
-                    self.logger.error("Failed to create decryptor: {0}", err)
-                    self.close()
-                    return
 
-                for chunk in res.iter_content(chunk_size):
-                    # If the input data is not a multiple of 16, cut off any garbage
-                    garbage_len = len(chunk) % 16
-                    if garbage_len:
-                        self.logger.debug("Cutting off {0} bytes of garbage "
-                                          "before decrypting", garbage_len)
-                        decrypted_chunk = decryptor.decrypt(chunk[:-garbage_len])
-                    else:
-                        decrypted_chunk = decryptor.decrypt(chunk)
-                    self.reader.buffer.write(decrypted_chunk)
-            else:
-                for chunk in res.iter_content(chunk_size):
-                    self.reader.buffer.write(chunk)
-        except StreamError as err:
-            self.logger.error("Failed to open segment {0}: {1}", sequence.num, err)
-            return self.write(sequence,
-                              self.fetch(sequence, retries=self.retries),
-                              chunk_size=chunk_size,
-                              retries=retries - 1)
+    def write(self, sequence, res, chunk_size=8192):
+        if sequence.segment.key and sequence.segment.key.method != "NONE":
+            try:
+                decryptor = self.create_decryptor(sequence.segment.key,
+                                                  sequence.num)
+            except StreamError as err:
+                self.logger.error("Failed to create decryptor: {0}", err)
+                self.close()
+                return
+
+            for chunk in res.iter_content(chunk_size):
+                # If the input data is not a multiple of 16, cut off any garbage
+                garbage_len = len(chunk) % 16
+                if garbage_len:
+                    self.logger.debug("Cutting off {0} bytes of garbage "
+                                      "before decrypting", garbage_len)
+                    decrypted_chunk = decryptor.decrypt(chunk[:-garbage_len])
+                else:
+                    decrypted_chunk = decryptor.decrypt(chunk)
+                self.reader.buffer.write(decrypted_chunk)
+        else:
+            for chunk in res.iter_content(chunk_size):
+                self.reader.buffer.write(chunk)
 
         self.logger.debug("Download of segment {0} complete", sequence.num)
 
@@ -139,6 +121,7 @@ class HLSStreamWorker(SegmentedStreamWorker):
         self.playlist_sequences = []
         self.playlist_reload_time = 15
         self.live_edge = self.session.options.get("hls-live-edge")
+        self.playlist_reload_retries = self.session.options.get("hls-playlist-reload-attempts")
 
         self.reload_playlist()
 
@@ -150,8 +133,8 @@ class HLSStreamWorker(SegmentedStreamWorker):
         self.logger.debug("Reloading playlist")
         res = self.session.http.get(self.stream.url,
                                     exception=StreamError,
+                                    retries=self.playlist_reload_retries,
                                     **self.reader.request_params)
-
         try:
             playlist = hls_playlist.load(res.text, res.url)
         except ValueError as err:
@@ -177,9 +160,6 @@ class HLSStreamWorker(SegmentedStreamWorker):
         if first_sequence.segment.key and first_sequence.segment.key.method != "NONE":
             self.logger.debug("Segments in this playlist are encrypted")
 
-            if not CAN_DECRYPT:
-                raise StreamError("Need pyCrypto or pycryptodome installed to decrypt this stream")
-
         self.playlist_changed = ([s.num for s in self.playlist_sequences] !=
                                  [s.num for s in sequences])
         self.playlist_reload_time = (playlist.target_duration or
@@ -247,7 +227,7 @@ class MuxedHLSStream(MuxedStream):
         substreams = map(lambda url: HLSStream(session, url, force_restart=force_restart, **args), [video, audio])
         ffmpeg_options = ffmpeg_options or {}
 
-        super(MuxedHLSStream, self).__init__(session, *substreams, **ffmpeg_options)
+        super(MuxedHLSStream, self).__init__(session, *substreams, format="mpegts", **ffmpeg_options)
 
 
 class HLSStream(HTTPStream):
@@ -318,6 +298,8 @@ class HLSStream(HTTPStream):
         streams = {}
         for playlist in filter(lambda p: not p.is_iframe, parser.playlists):
             names = dict(name=None, pixels=None, bitrate=None)
+            audio_streams = []
+            fallback_audio = None
             default_audio = None
             preferred_audio = None
 
@@ -325,16 +307,24 @@ class HLSStream(HTTPStream):
                 if media.type == "VIDEO" and media.name:
                     names["name"] = media.name
                 elif media.type == "AUDIO":
-                    if media.default:
-                        default_audio = media
-                    # if the media is "audoselect" and it better matches the users preferences, use that
-                    # instead of default
-                    if media.autoselect and locale.equivalent(language=media.language):
-                        default_audio = media
+                    audio_streams.append(media)
+
+            for media in audio_streams:
+                if not fallback_audio and media.default:
+                    fallback_audio = media
+
+                # if the media is "audoselect" and it better matches the users preferences, use that
+                # instead of default
+                if not default_audio and (media.autoselect and locale.equivalent(language=media.language)):
+                    default_audio = media
+
+                # select the first audio stream that matches the users explict language selection
+                if (not preferred_audio or media.default) and locale.explicit and locale.equivalent(
+                        language=media.language):
+                    preferred_audio = media
 
-                    # select the first audio stream that matches the users explict language selection
-                    if not preferred_audio and locale.explicit and locale.equivalent(language=media.language):
-                        preferred_audio = media
+            # final fallback on the first audio stream listed
+            fallback_audio = fallback_audio or (len(audio_streams) and audio_streams[0])
 
             if playlist.stream_info.resolution:
                 width, height = playlist.stream_info.resolution
@@ -366,14 +356,16 @@ class HLSStream(HTTPStream):
             if check_streams:
                 try:
                     session_.http.get(playlist.uri, **request_params)
+                except KeyboardInterrupt:
+                    raise
                 except Exception:
                     continue
 
-            external_audio = preferred_audio or default_audio
+            external_audio = preferred_audio or default_audio or fallback_audio
             if external_audio and external_audio.uri and FFMPEGMuxer.is_usable(session_):
-                logger.debug("Using external audio track for stream {0} (language={1})".format(
+                logger.debug("Using external audio track for stream {0} (language={1}, name={2})".format(
                     name_prefix + stream_name,
-                    external_audio.language))
+                    external_audio.language, external_audio.name or "N/A"))
 
                 stream = MuxedHLSStream(session_,
                                         video=playlist.uri,
diff --git a/src/streamlink/stream/http.py b/src/streamlink/stream/http.py
index 829064d7..09752111 100644
--- a/src/streamlink/stream/http.py
+++ b/src/streamlink/stream/http.py
@@ -78,3 +78,6 @@ class HTTPStream(Stream):
             fd = StreamIOThreadWrapper(self.session, fd, timeout=timeout)
 
         return fd
+
+    def to_url(self):
+        return self.url
diff --git a/src/streamlink/stream/rtmpdump.py b/src/streamlink/stream/rtmpdump.py
index c254173e..2b24a4a6 100644
--- a/src/streamlink/stream/rtmpdump.py
+++ b/src/streamlink/stream/rtmpdump.py
@@ -1,13 +1,12 @@
 import re
 
-from time import sleep
-
 import subprocess
+from operator import itemgetter
 
 from streamlink.stream.streamprocess import StreamProcess
 from streamlink.compat import str, which
 from streamlink.exceptions import StreamError
-from streamlink.utils import rtmpparse
+from streamlink.utils import rtmpparse, escape_librtmp
 
 
 class RTMPStream(StreamProcess):
@@ -127,3 +126,26 @@ class RTMPStream(StreamProcess):
         cmd = session.options.get("rtmp-rtmpdump")
 
         return which(cmd) is not None
+
+    def to_url(self):
+        stream_params = dict(self.params)
+        params = [stream_params.pop("rtmp", "")]
+
+        if "swfVfy" in self.params:
+            stream_params["swfUrl"] = self.params["swfVfy"]
+            stream_params["swfVfy"] = True
+
+        if "swfhash" in self.params:
+            stream_params["swfVfy"] = True
+            stream_params.pop("swfhash", None)
+            stream_params.pop("swfsize", None)
+
+        # sort the keys for stability of output
+        for key, value in sorted(stream_params.items(), key=itemgetter(0)):
+            if isinstance(value, list):
+                for svalue in value:
+                    params.append("{0}={1}".format(key, escape_librtmp(svalue)))
+            else:
+                params.append("{0}={1}".format(key, escape_librtmp(value)))
+
+        return " ".join(params)
diff --git a/src/streamlink/stream/stream.py b/src/streamlink/stream/stream.py
index 81132c2f..60acebe4 100644
--- a/src/streamlink/stream/stream.py
+++ b/src/streamlink/stream/stream.py
@@ -37,6 +37,9 @@ class Stream(object):
     def shortname(cls):
         return cls.__shortname__
 
+    def to_url(self):
+        raise TypeError("{0} cannot be converted to a URL".format(self.shortname()))
+
 
 class StreamIO(io.IOBase):
     pass
diff --git a/src/streamlink/stream/streamprocess.py b/src/streamlink/stream/streamprocess.py
index 6377eb03..99bdb2f3 100644
--- a/src/streamlink/stream/streamprocess.py
+++ b/src/streamlink/stream/streamprocess.py
@@ -124,12 +124,12 @@ class StreamProcess(Stream):
         """
         stderr = stderr or self.stderr
         cmd = self.bake(self._check_cmd(), parameters, arguments, short_option_prefix, long_option_prefix)
-        self.logger.debug("Spawning command: {}", subprocess.list2cmdline(cmd))
+        self.logger.debug("Spawning command: {0}", subprocess.list2cmdline(cmd))
 
         try:
             process = subprocess.Popen(cmd, stderr=stderr, stdout=subprocess.PIPE)
         except (OSError, IOError) as err:
-            raise StreamError("Failed to start process: {} ({})".format(self._check_cmd(), str(err)))
+            raise StreamError("Failed to start process: {0} ({1})".format(self._check_cmd(), str(err)))
 
         if timeout:
             elapsed = 0
@@ -140,7 +140,7 @@ class StreamProcess(Stream):
             # kill after the timeout has expired and the process still hasn't ended
             if not process.poll():
                 try:
-                    self.logger.debug("Process timeout expired ({}s), killing process".format(timeout))
+                    self.logger.debug("Process timeout expired ({0}s), killing process".format(timeout))
                     process.kill()
                 except Exception:
                     pass
diff --git a/src/streamlink/utils/__init__.py b/src/streamlink/utils/__init__.py
index ff6e63af..12d4195c 100644
--- a/src/streamlink/utils/__init__.py
+++ b/src/streamlink/utils/__init__.py
@@ -134,6 +134,21 @@ def rtmpparse(url):
     return (tcurl, playpath)
 
 
+def update_scheme(current, target):
+    """
+    Take the scheme from the current URL and applies it to the
+    target URL if the target URL startswith //
+    :param current: current URL
+    :param target: target URL
+    :return: target URL with the current URLs schema
+    """
+    scheme = urlparse(current).scheme
+    if target.startswith("//"):
+        return "{0}:{1}".format(scheme, target)
+    else:
+        return target
+
+
 #####################################
 # Deprecated functions, do not use. #
 #####################################
@@ -220,6 +235,19 @@ def swfverify(url):  # pragma: no cover
     return h.hexdigest(), len(swf)
 
 
+def escape_librtmp(value):
+    if isinstance(value, bool):
+        value = "1" if value else "0"
+    if isinstance(value, int):
+        value = str(value)
+
+    # librtmp expects some characters to be escaped
+    value = value.replace("\\", "\\5c")
+    value = value.replace(" ", "\\20")
+    value = value.replace('"', "\\22")
+    return value
+
 __all__ = ["urlopen", "urlget", "urlresolve", "swfdecompress", "swfverify",
            "verifyjson", "absolute_url", "parse_qsd", "parse_json", "res_json",
-           "parse_xml", "res_xml", "rtmpparse", "prepend_www", "NamedPipe"]
+           "parse_xml", "res_xml", "rtmpparse", "prepend_www", "NamedPipe",
+           "escape_librtmp"]
diff --git a/src/streamlink/utils/l10n.py b/src/streamlink/utils/l10n.py
index 80083609..13ae1f7b 100644
--- a/src/streamlink/utils/l10n.py
+++ b/src/streamlink/utils/l10n.py
@@ -1,12 +1,110 @@
 import locale
-from iso639 import languages
-from iso3166 import countries
+
+from streamlink.compat import is_py2
+
+try:
+    from iso639 import languages
+    from iso3166 import countries
+
+    PYCOUNTRY = False
+except ImportError:  # pragma: no cover
+    from pycountry import languages, countries
+
+    PYCOUNTRY = True
 
 DEFAULT_LANGUAGE_CODE = "en_US"
 
 
-class Localization(object):
+class Country(object):
+    def __init__(self, alpha2, alpha3, numeric, name, official_name=None):
+        self.alpha2 = alpha2
+        self.alpha3 = alpha3
+        self.numeric = numeric
+        self.name = name
+        self.official_name = official_name
+
+    @classmethod
+    def get(cls, country):
+        try:
+            if PYCOUNTRY:
+                c = countries.lookup(country)
+                return Country(c.alpha_2, c.alpha_3, c.numeric, c.name, c.official_name)
+            else:
+                c = countries.get(country)
+                return Country(c.alpha2, c.alpha3, c.numeric, c.name, c.apolitical_name)
+        except (LookupError, KeyError):
+            raise LookupError("Invalid country code: {0}".format(country))
+
+    def __eq__(self, other):
+        return ((self.alpha2 and self.alpha2 == other.alpha2)
+                or (self.alpha3 and self.alpha3 == other.alpha3)
+                or (self.numeric and self.numeric == other.numeric))
+
+    def __str__(self):
+        if is_py2:
+            return self.__unicode__().encode("utf8")
+        else:
+            return self.__unicode__()
+
+    def __unicode__(self):
+        return u"Country({0!r}, {1!r}, {2!r}, {3!r}, official_name={4!r})".format(self.alpha2,
+                                                                                  self.alpha3,
+                                                                                  self.numeric,
+                                                                                  self.name,
+                                                                                  self.official_name)
+
 
+class Language(object):
+    def __init__(self, alpha2, alpha3, name, bibliographic=None):
+        self.alpha2 = alpha2
+        self.alpha3 = alpha3
+        self.name = name
+        self.bibliographic = bibliographic
+
+    @classmethod
+    def get(cls, language):
+        try:
+            if PYCOUNTRY:
+                c = languages.lookup(language)
+                return Language(c.alpha_2, c.alpha_3, c.name, getattr(c, "bibliographic", None))
+            else:
+                l = None
+                if len(language) == 2:
+                    l = languages.get(alpha2=language)
+                elif len(language) == 3:
+                    for code_type in ['part2b', 'part2t', 'part3']:
+                        try:
+                            l = languages.get(**{code_type: language})
+                            break
+                        except KeyError:
+                            pass
+                    if not l:
+                        raise KeyError(language)
+                else:
+                    raise KeyError(language)
+                return Language(l.alpha2, l.part3, l.name, l.part2b or l.part2t)
+        except (LookupError, KeyError):
+            raise LookupError("Invalid language code: {0}".format(language))
+
+    def __eq__(self, other):
+        return ((self.alpha2 and self.alpha2 == other.alpha2)
+                or (self.alpha3 and self.alpha3 == other.alpha3)
+                or (self.bibliographic and self.bibliographic == other.bibliographic))
+
+    def __str__(self):
+        if is_py2:
+            return self.__unicode__().encode("utf8")
+        else:
+            return self.__unicode__()
+
+    def __unicode__(self):
+        return u"Language({0!r}, {1!r}, {2!r}, bibliographic={3!r})".format(self.alpha2,
+                                                                            self.alpha3,
+                                                                            self.name,
+                                                                            self.bibliographic)
+
+
+class Localization(object):
     def __init__(self, language_code=None):
         self._language_code = None
         self.country = None
@@ -21,7 +119,10 @@ class Localization(object):
     @language_code.setter
     def language_code(self, language_code):
         if language_code is None:
-            language_code, _ = locale.getdefaultlocale()
+            try:
+                language_code, _ = locale.getdefaultlocale()
+            except ValueError:
+                language_code = None
             if language_code is None or language_code == "C":
                 # cannot be determined
                 language_code = DEFAULT_LANGUAGE_CODE
@@ -29,7 +130,7 @@ class Localization(object):
         parts = language_code.split("_", 1)
 
         if len(parts) != 2 or len(parts[0]) != 2 or len(parts[1]) != 2:
-            raise ValueError("Invalid language code: {0}".format(language_code))
+            raise LookupError("Invalid language code: {0}".format(language_code))
 
         self._language_code = language_code
         self.language = self.get_language(parts[0])
@@ -40,7 +141,7 @@ class Localization(object):
         try:
             equivalent = equivalent and (not language or self.language == self.get_language(language))
             equivalent = equivalent and (not country or self.country == self.get_country(country))
-        except KeyError:
+        except LookupError:
             # if an unknown language/country code is given they cannot equivalent
             return False
 
@@ -48,24 +149,8 @@ class Localization(object):
 
     @classmethod
     def get_country(cls, country):
-        try:
-            return countries.get(country)
-        except KeyError:
-            raise ValueError("Invalid country code: {0}".format(country))
+        return Country.get(country)
 
     @classmethod
     def get_language(cls, language):
-        try:
-            if len(language) == 2:
-                return languages.get(alpha2=language)
-            elif len(language) == 3:
-                for code_type in ['part2b', 'part2t', 'part3']:
-                    try:
-                        return languages.get(**{code_type: language})
-                    except KeyError:
-                        pass
-                raise KeyError
-            else:
-                raise ValueError("Invalid language code: {0}".format(language))
-        except KeyError:
-            raise ValueError("Invalid language code: {0}".format(language))
+        return Language.get(language)
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 19c21d5c..2e2d0708 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -600,6 +600,17 @@ transport.add_argument(
     Default is 3.
     """
 )
+transport.add_argument(
+    "--hls-playlist-reload-attempts",
+    type=num(int, min=0),
+    metavar="ATTEMPTS",
+    help="""
+    How many attempts should be done to reload the HLS playlist
+    before giving up.
+
+    Default is 3.
+    """
+)
 transport.add_argument(
     "--hls-segment-threads",
     type=num(int, max=10),
@@ -1023,6 +1034,14 @@ plugin.add_argument(
     and reauthenticate.
     """
 )
+plugin.add_argument(
+    "--crunchyroll-session-id",
+    metavar="SESSION_ID",
+    help="""
+    Set a specific session ID for crunchyroll, can be used to bypass
+    region restrictions
+    """
+)
 plugin.add_argument(
     "--livestation-email",
     metavar="EMAIL",
diff --git a/src/streamlink_cli/console.py b/src/streamlink_cli/console.py
index 5db4ee33..aad08721 100644
--- a/src/streamlink_cli/console.py
+++ b/src/streamlink_cli/console.py
@@ -40,7 +40,7 @@ class ConsoleOutput(object):
 
     def msg(self, msg, *args, **kwargs):
         formatted = msg.format(*args, **kwargs)
-        formatted = "{0}\n".format(formatted)
+        formatted = u"{0}\n".format(formatted)
 
         self.output.write(formatted)
 
@@ -53,7 +53,7 @@ class ConsoleOutput(object):
 
         msg = json.dumps(obj, cls=JSONEncoder,
                          indent=2)
-        self.msg("{0}", msg)
+        self.msg(u"{0}", msg)
 
         if isinstance(obj, dict) and obj.get("error"):
             sys.exit(1)
@@ -65,8 +65,8 @@ class ConsoleOutput(object):
             obj = dict(error=formatted)
             self.msg_json(obj)
         else:
-            msg = "error: {0}".format(formatted)
-            self.msg("{0}", msg)
+            msg = u"error: {0}".format(formatted)
+            self.msg(u"{0}", msg)
 
         sys.exit(1)
 
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index b823aa62..3a1d5204 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -349,10 +349,9 @@ def handle_stream(plugin, streams, stream_name):
         console.msg_json(stream)
 
     elif args.stream_url:
-        url = stream_to_url(stream)
-        if url:
-            console.msg("{0}", url)
-        else:
+        try:
+            console.msg("{0}", stream.to_url())
+        except TypeError:
             console.exit("The stream specified cannot be translated to a URL")
 
     # Output the stream
@@ -484,7 +483,7 @@ def handle_url():
         console.exit(u"{0}", err)
 
     if not streams:
-        console.exit("No streams found on this URL: {0}", args.url)
+        console.exit("No playable streams found on this URL: {0}", args.url)
 
     if args.best_stream_default:
         args.default_stream = ["best"]
@@ -707,6 +706,9 @@ def setup_options():
     if args.hls_segment_attempts:
         streamlink.set_option("hls-segment-attempts", args.hls_segment_attempts)
 
+    if args.hls_playlist_reload_attempts:
+        streamlink.set_option("hls-playlist-reload-attempts", args.hls_playlist_reload_attempts)
+
     if args.hls_segment_threads:
         streamlink.set_option("hls-segment-threads", args.hls_segment_threads)
 
@@ -814,6 +816,9 @@ def setup_plugin_options():
     if args.crunchyroll_purge_credentials:
         streamlink.set_plugin_option("crunchyroll", "purge_credentials",
                                      args.crunchyroll_purge_credentials)
+    if args.crunchyroll_session_id:
+        streamlink.set_plugin_option("crunchyroll", "session_id",
+                                     args.crunchyroll_session_id)
 
     if args.crunchyroll_locale:
         streamlink.set_plugin_option("crunchyroll", "locale",
diff --git a/src/streamlink_cli/output.py b/src/streamlink_cli/output.py
index 15db472e..f86a4933 100644
--- a/src/streamlink_cli/output.py
+++ b/src/streamlink_cli/output.py
@@ -97,8 +97,7 @@ class PlayerOutput(Output):
     @property
     def running(self):
         sleep(0.5)
-        self.player.poll()
-        return self.player.returncode is None
+        return self.player.poll() is None
 
     def _create_arguments(self):
         if self.namedpipe:
diff --git a/src/streamlink_cli/utils/stream.py b/src/streamlink_cli/utils/stream.py
index aa27fbbc..f65305c9 100644
--- a/src/streamlink_cli/utils/stream.py
+++ b/src/streamlink_cli/utils/stream.py
@@ -1,45 +1,5 @@
-def escape_librtmp(value):
-    if isinstance(value, bool):
-        value = "1" if value else "0"
-    if isinstance(value, int):
-        value = str(value)
-
-    # librtmp expects some characters to be escaped
-    value = value.replace("\\", "\\5c")
-    value = value.replace(" ", "\\20")
-    value = value.replace('"', "\\22")
-    return value
-
-
 def stream_to_url(stream):
-    stream_type = type(stream).shortname()
-
-    if stream_type in ("hls", "http"):
-        url = stream.url
-
-    elif stream_type == "rtmp":
-        params = [stream.params.pop("rtmp", "")]
-        stream_params = dict(stream.params)
-
-        if "swfVfy" in stream.params:
-            stream_params["swfUrl"] = stream.params["swfVfy"]
-            stream_params["swfVfy"] = True
-
-        if "swfhash" in stream.params:
-            stream_params["swfVfy"] = True
-            stream_params.pop("swfhash", None)
-            stream_params.pop("swfsize", None)
-
-        for key, value in stream_params.items():
-            if isinstance(value, list):
-                for svalue in value:
-                    params.append("{0}={1}".format(key, escape_librtmp(svalue)))
-            else:
-                params.append("{0}={1}".format(key, escape_librtmp(value)))
-
-        url = " ".join(params)
-
-    else:
-        url = None
-
-    return url
+    try:
+        return stream.to_url()
+    except TypeError:
+        return None


Test patch:
diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py
index c2972877..21e40574 100644
--- a/tests/test_cmdline.py
+++ b/tests/test_cmdline.py
@@ -32,8 +32,14 @@ class TestCommandLineInvocation(unittest.TestCase):
     @patch('sys.argv')
     def _test_args(self, args, commandline, mock_argv, mock_popen, mock_setup_streamlink, passthrough=False):
         mock_argv.__getitem__.side_effect = lambda x: args[x]
-        mock_popen().returncode = None
-        mock_popen().poll.return_value = None
+
+        def side_effect(results):
+            def fn(*args):
+                result = results.pop(0)
+                return result
+            return fn
+
+        mock_popen().poll.side_effect = side_effect([None, 0])
 
         streamlink_cli.main.main()
         mock_setup_streamlink.assert_called_with()
diff --git a/tests/test_localization.py b/tests/test_localization.py
index 42cea044..94cadc41 100644
--- a/tests/test_localization.py
+++ b/tests/test_localization.py
@@ -1,34 +1,50 @@
 import unittest
+
 try:
     from unittest.mock import patch
 except ImportError:
     from mock import patch
 
-from streamlink.utils.l10n import Localization
+import streamlink.utils.l10n as l10n
+
+try:
+    import iso639
+    import iso3166
+
+    ISO639 = True
+except ImportError:
+    ISO639 = False
+
+try:
+    import pycountry
 
+    PYCOUNTRY = True
+except ImportError:
+    PYCOUNTRY = False
 
-class TestLocalization(unittest.TestCase):
+
+class LocalizationTestsMixin(object):
     def test_language_code(self):
-        l = Localization("en_US")
+        l = l10n.Localization("en_US")
         self.assertEqual("en_US", l.language_code)
 
     def test_bad_language_code(self):
-        self.assertRaises(ValueError, Localization, "enUS")
+        self.assertRaises(LookupError, l10n.Localization, "enUS")
 
     def test_equivalent(self):
-        l = Localization("en_US")
+        l = l10n.Localization("en_US")
         self.assertTrue(l.equivalent(language="eng"))
         self.assertTrue(l.equivalent(language="en"))
         self.assertTrue(l.equivalent(language="en", country="US"))
         self.assertTrue(l.equivalent(language="en", country="United States"))
 
     def test_equivalent_remap(self):
-        l = Localization("fr_FR")
+        l = l10n.Localization("fr_FR")
         self.assertTrue(l.equivalent(language="fra"))
         self.assertTrue(l.equivalent(language="fre"))
 
     def test_not_equivalent(self):
-        l = Localization("es_ES")
+        l = l10n.Localization("es_ES")
         self.assertFalse(l.equivalent(language="eng"))
         self.assertFalse(l.equivalent(language="en"))
         self.assertFalse(l.equivalent(language="en", country="US"))
@@ -39,34 +55,71 @@ class TestLocalization(unittest.TestCase):
     @patch("locale.getdefaultlocale")
     def test_default(self, getdefaultlocale):
         getdefaultlocale.return_value = (None, None)
-        l = Localization()
+        l = l10n.Localization()
         self.assertEqual("en_US", l.language_code)
         self.assertTrue(l.equivalent(language="en", country="US"))
 
     def test_get_country(self):
         self.assertEqual("US",
-                         Localization.get_country("USA").alpha2)
+                         l10n.Localization.get_country("USA").alpha2)
         self.assertEqual("GB",
-                         Localization.get_country("GB").alpha2)
+                         l10n.Localization.get_country("GB").alpha2)
         self.assertEqual("United States",
-                         Localization.get_country("United States").name)
+                         l10n.Localization.get_country("United States").name)
 
     def test_get_country_miss(self):
-        self.assertRaises(ValueError, Localization.get_country, "XE")
-        self.assertRaises(ValueError, Localization.get_country, "XEX")
-        self.assertRaises(ValueError, Localization.get_country, "Nowhere")
+        self.assertRaises(LookupError, l10n.Localization.get_country, "XE")
+        self.assertRaises(LookupError, l10n.Localization.get_country, "XEX")
+        self.assertRaises(LookupError, l10n.Localization.get_country, "Nowhere")
 
     def test_get_language(self):
         self.assertEqual("eng",
-                         Localization.get_language("en").part2b)
-        self.assertEqual("fre",
-                         Localization.get_language("fra").part2b)
+                         l10n.Localization.get_language("en").alpha3)
         self.assertEqual("fre",
-                         Localization.get_language("fre").part2b)
+                         l10n.Localization.get_language("fra").bibliographic)
+        self.assertEqual("fra",
+                         l10n.Localization.get_language("fre").alpha3)
         self.assertEqual("gre",
-                         Localization.get_language("gre").part2b)
+                         l10n.Localization.get_language("gre").bibliographic)
 
     def test_get_language_miss(self):
-        self.assertRaises(ValueError, Localization.get_language, "00")
-        self.assertRaises(ValueError, Localization.get_language, "000")
-        self.assertRaises(ValueError, Localization.get_language, "0000")
+        self.assertRaises(LookupError, l10n.Localization.get_language, "00")
+        self.assertRaises(LookupError, l10n.Localization.get_language, "000")
+        self.assertRaises(LookupError, l10n.Localization.get_language, "0000")
+
+    def test_country_compare(self):
+        a = l10n.Country("AA", "AAA", "001", "Test")
+        b = l10n.Country("AA", "AAA", "001", "Test")
+        self.assertEqual(a, b)
+
+    def test_language_compare(self):
+        a = l10n.Language("AA", "AAA", "Test")
+        b = l10n.Language("AA", None, "Test")
+        self.assertEqual(a, b)
+
+        a = l10n.Language("BB", "BBB", "Test")
+        b = l10n.Language("AA", None, "Test")
+        self.assertNotEqual(a, b)
+
+
+@unittest.skipIf(not ISO639, "iso639+iso3166 modules are required to test iso639+iso3166 Localization")
+class TestLocalization(LocalizationTestsMixin, unittest.TestCase):
+    def setUp(self):
+        l10n.PYCOUNTRY = False
+
+    def test_pycountry(self):
+        self.assertEqual(False, l10n.PYCOUNTRY)
+
+
+@unittest.skipIf(not PYCOUNTRY, "pycountry module required to test pycountry Localization")
+class TestLocalizationPyCountry(LocalizationTestsMixin, unittest.TestCase):
+    """Duplicate of all the Localization tests but using PyCountry instead of the iso* modules"""
+
+    def setUp(self):
+        from pycountry import languages, countries
+        l10n.countries = countries
+        l10n.languages = languages
+        l10n.PYCOUNTRY = True
+
+    def test_pycountry(self):
+        self.assertEqual(True, l10n.PYCOUNTRY)
diff --git a/tests/test_plugin_bongacams.py b/tests/test_plugin_bongacams.py
new file mode 100644
index 00000000..1d0b3100
--- /dev/null
+++ b/tests/test_plugin_bongacams.py
@@ -0,0 +1,89 @@
+import unittest
+
+from streamlink.plugins.bongacams import bongacams
+
+
+class TestPluginBongacams(unittest.TestCase):
+    def test_can_handle_url(self):
+        """ positive cases """
+        self.assertTrue(bongacams.can_handle_url('http://bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('http://bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('http://bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('http://bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('http://xx.bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('http://xx.bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('http://xx.bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('http://xx.bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('https://bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('https://bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('https://bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('https://bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('https://xx.bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('https://xx.bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('https://xx.bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('https://xx.bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('xx.bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('xx.bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('xx.bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('xx.bongacams.com/TEst'))
+
+        """ Negative cases """
+        # invalid schema
+        self.assertFalse(bongacams.can_handle_url("test://bongacams.com/test"))
+
+        # invalid stream path
+        self.assertFalse(bongacams.can_handle_url('http://bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('http://bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('http://bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('http://bongacams.com/\test'))
+        self.assertFalse(bongacams.can_handle_url('http://xx.bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('http://xx.bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('http://xx.bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('http://xx.bongacams.com/\\test'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.com/\test'))
+        self.assertFalse(bongacams.can_handle_url('https://xx.bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('https://xx.bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('https://xx.bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('https://xx.bongacams.com/\test'))
+        self.assertFalse(bongacams.can_handle_url('bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('bongacams.com/\test'))
+        self.assertFalse(bongacams.can_handle_url('xx.bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('xx.bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('xx.bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('xx.bongacams.com/\test'))
+
+        # invalid domain
+        self.assertFalse(bongacams.can_handle_url('https:// /test'))
+        self.assertFalse(bongacams.can_handle_url('https://xxx.bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https://x.bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https://x.bongacams/test'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.jp/test'))
+        self.assertFalse(bongacams.can_handle_url('https://test.domain/test'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.co.uk/test'))
+
+        # invalid separator
+        self.assertFalse(bongacams.can_handle_url('http bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url(' bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('http//bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https//bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('//bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('http:///bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https:///bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url(':///bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('httptestbongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('httpstestbongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('testbongacams.com/test'))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_plugin_gulli.py b/tests/test_plugin_gulli.py
new file mode 100644
index 00000000..21cb0837
--- /dev/null
+++ b/tests/test_plugin_gulli.py
@@ -0,0 +1,20 @@
+import unittest
+
+from streamlink.plugins.gulli import Gulli
+
+
+class TestPluginGulli(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(Gulli.can_handle_url("http://replay.gulli.fr/Direct"))
+        self.assertTrue(Gulli.can_handle_url("http://replay.gulli.fr/dessins-animes/My-Little-Pony-les-amies-c-est-magique/VOD68328764799000"))
+        self.assertTrue(Gulli.can_handle_url("http://replay.gulli.fr/emissions/In-Ze-Boite2/VOD68639028668000"))
+        self.assertTrue(Gulli.can_handle_url("http://replay.gulli.fr/series/Power-Rangers-Dino-Super-Charge/VOD68612908435000"))
+
+        # shouldn't match
+        self.assertFalse(Gulli.can_handle_url("http://replay.gulli.fr/"))
+        self.assertFalse(Gulli.can_handle_url("http://replay.gulli.fr/dessins-animes"))
+        self.assertFalse(Gulli.can_handle_url("http://replay.gulli.fr/emissions"))
+        self.assertFalse(Gulli.can_handle_url("http://replay.gulli.fr/series"))
+        self.assertFalse(Gulli.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(Gulli.can_handle_url("http://www.youtube.com/"))
diff --git a/tests/test_plugin_huomao.py b/tests/test_plugin_huomao.py
new file mode 100644
index 00000000..f5e42b55
--- /dev/null
+++ b/tests/test_plugin_huomao.py
@@ -0,0 +1,99 @@
+# Unit tests accompanying the Huomao Streamlink plugin.
+
+import unittest
+import re
+
+from streamlink.plugin.api import http
+from streamlink.plugins.huomao import Huomao
+
+class TestPluginHuomao(unittest.TestCase):
+
+    def setUp(self):
+
+        # Create a mock source HTML with some example data:
+        #   room_id             = 123456
+        #   stream_id           = 9qsvyF24659
+        #   stream_url          = http://live-ws.huomaotv.cn/live/
+        #   stream_quality      = source, _720 and _480
+        #   stream_quality_name = source, 720 and 480
+        self.mock_html = """
+            <input id="html_stream" value="9qsvyF24659" type="hidden">
+            <!--            urls:{-->
+            <!--                1: 'http://live-ws.huomaotv.cn/live/'+stream+'/playlist.m3u8',-->
+            <!--                2: 'http://live-ws.huomaotv.cn/live/'+stream+'_720/playlist.m3u8',-->
+            <!--                3: 'http://live-ws.huomaotv.cn/live/'+stream+'_480/playlist.m3u8'-->
+            <!--            },-->
+        """
+
+        # Create a mock Huomao object.
+        self.mock_huomao = Huomao("http://www.huomao.com/123456/")
+
+    def tearDown(self):
+        self.mock_html = None
+        self.mock_huomao = None
+
+    def test_get_stream_id(self):
+
+        # Assert that the stream_id from is correctly extracted from the mock HTML.
+        self.assertEqual(self.mock_huomao.get_stream_id(self.mock_html), "9qsvyF24659")
+
+    def test_get_stream_quality(self):
+
+        # Assert that the stream_url, stream_quality and stream_quality_name
+        # is correctly extracted from the mock HTML.
+        self.assertEqual(self.mock_huomao.get_stream_info(self.mock_html), [
+            ["http://live-ws.huomaotv.cn/live/", "", "source"],
+            ["http://live-ws.huomaotv.cn/live/", "_720", "720"],
+            ["http://live-ws.huomaotv.cn/live/", "_480", "480"]
+        ])
+
+    def test_can_handle_url(self):
+
+        # Assert that an URL containing the http:// prefix is correctly read.
+        self.assertTrue(Huomao.can_handle_url("http://www.huomao.com/123456"))
+        self.assertTrue(Huomao.can_handle_url("http://www.huomao.tv/123456"))
+        self.assertTrue(Huomao.can_handle_url("http://huomao.com/123456"))
+        self.assertTrue(Huomao.can_handle_url("http://huomao.tv/123456"))
+
+        # Assert that an URL containing the https:// prefix is correctly read.
+        self.assertTrue(Huomao.can_handle_url("https://www.huomao.com/123456"))
+        self.assertTrue(Huomao.can_handle_url("https://www.huomao.tv/123456"))
+        self.assertTrue(Huomao.can_handle_url("https://huomao.com/123456"))
+        self.assertTrue(Huomao.can_handle_url("https://huomao.tv/123456"))
+
+        # Assert that an URL without the http(s):// prefix is correctly read.
+        self.assertTrue(Huomao.can_handle_url("www.huomao.com/123456"))
+        self.assertTrue(Huomao.can_handle_url("www.huomao.tv/123456"))
+
+        # Assert that an URL without the www prefix is correctly read.
+        self.assertTrue(Huomao.can_handle_url("huomao.com/123456"))
+        self.assertTrue(Huomao.can_handle_url("huomao.tv/123456"))
+
+        # Assert that an URL without a room_id can't be read.
+        self.assertFalse(Huomao.can_handle_url("http://www.huomao.com/"))
+        self.assertFalse(Huomao.can_handle_url("http://www.huomao.tv/"))
+        self.assertFalse(Huomao.can_handle_url("http://huomao.com/"))
+        self.assertFalse(Huomao.can_handle_url("http://huomao.tv/"))
+        self.assertFalse(Huomao.can_handle_url("https://www.huomao.com/"))
+        self.assertFalse(Huomao.can_handle_url("https://www.huomao.tv/"))
+        self.assertFalse(Huomao.can_handle_url("https://huomao.com/"))
+        self.assertFalse(Huomao.can_handle_url("https://huomao.tv/"))
+        self.assertFalse(Huomao.can_handle_url("www.huomao.com/"))
+        self.assertFalse(Huomao.can_handle_url("www.huomao.tv/"))
+        self.assertFalse(Huomao.can_handle_url("huomao.tv/"))
+        self.assertFalse(Huomao.can_handle_url("huomao.tv/"))
+
+        # Assert that an URL without "huomao" can't be read.
+        self.assertFalse(Huomao.can_handle_url("http://www.youtube.com/123456"))
+        self.assertFalse(Huomao.can_handle_url("http://www.youtube.tv/123456"))
+        self.assertFalse(Huomao.can_handle_url("http://youtube.com/123456"))
+        self.assertFalse(Huomao.can_handle_url("http://youtube.tv/123456"))
+        self.assertFalse(Huomao.can_handle_url("https://www.youtube.com/123456"))
+        self.assertFalse(Huomao.can_handle_url("https://www.youtube.tv/123456"))
+        self.assertFalse(Huomao.can_handle_url("https://youtube.com/123456"))
+        self.assertFalse(Huomao.can_handle_url("https://youtube.tv/123456"))
+
+# Possibility to run unittest separately as a normal python script.
+if __name__ == "__main__":
+    suite = unittest.TestLoader().loadTestsFromTestCase(TestPluginHuomao)
+    unittest.TextTestRunner(verbosity = 2).run(suite)
diff --git a/tests/test_stream_to_url.py b/tests/test_stream_to_url.py
new file mode 100644
index 00000000..5e9712ed
--- /dev/null
+++ b/tests/test_stream_to_url.py
@@ -0,0 +1,81 @@
+import unittest
+
+try:
+    from unittest.mock import MagicMock, patch, PropertyMock
+except ImportError:
+    from mock import MagicMock, patch, PropertyMock
+
+from streamlink import Streamlink
+from streamlink.plugins.filmon import FilmOnHLS
+from streamlink.stream import AkamaiHDStream
+from streamlink.stream import HDSStream
+from streamlink.stream import HLSStream
+from streamlink.stream import HTTPStream
+from streamlink.stream import RTMPStream
+from streamlink.stream import Stream
+from streamlink_cli.utils import stream_to_url
+
+
+class TestStreamToURL(unittest.TestCase):
+    def setUp(self):
+        self.session = Streamlink()
+
+    def test_base_stream(self):
+        stream = Stream(self.session)
+        self.assertEqual(None, stream_to_url(stream))
+        self.assertRaises(TypeError, stream.to_url)
+
+    def test_http_stream(self):
+        expected = "http://test.se/stream"
+        stream = HTTPStream(self.session, expected)
+        self.assertEqual(expected, stream_to_url(stream))
+        self.assertEqual(expected, stream.to_url())
+
+    def test_hls_stream(self):
+        expected = "http://test.se/stream.m3u8"
+        stream = HLSStream(self.session, expected)
+        self.assertEqual(expected, stream_to_url(stream))
+        self.assertEqual(expected, stream.to_url())
+
+    def test_hds_stream(self):
+        stream = HDSStream(self.session, "http://test.se/", "http://test.se/stream.f4m", "http://test.se/stream/1.bootstrap")
+        self.assertEqual(None, stream_to_url(stream))
+        self.assertRaises(TypeError, stream.to_url)
+
+    def test_akamai_stream(self):
+        stream = AkamaiHDStream(self.session, "http://akamai.test.se/stream")
+        self.assertEqual(None, stream_to_url(stream))
+        self.assertRaises(TypeError, stream.to_url)
+
+    def test_rtmp_stream(self):
+        stream = RTMPStream(self.session, {"rtmp": "rtmp://test.se/app/play_path",
+                                           "swfVfy": "http://test.se/player.swf",
+                                           "swfhash": "test",
+                                           "swfsize": 123456,
+                                           "playPath": "play_path"})
+        expected = "rtmp://test.se/app/play_path playPath=play_path swfUrl=http://test.se/player.swf swfVfy=1"
+        self.assertEqual(expected, stream_to_url(stream))
+        self.assertEqual(expected, stream.to_url())
+
+    @patch("time.time")
+    @patch("streamlink.plugins.filmon.FilmOnHLS.url", new_callable=PropertyMock)
+    def test_filmon_stream(self, url, time):
+        stream = FilmOnHLS(self.session, channel="test")
+        url.return_value = "http://filmon.test.se/test.m3u8"
+        stream.watch_timeout = 10
+        time.return_value = 1
+        expected = "http://filmon.test.se/test.m3u8"
+
+        self.assertEqual(expected, stream_to_url(stream))
+        self.assertEqual(expected, stream.to_url())
+
+    @patch("time.time")
+    @patch("streamlink.plugins.filmon.FilmOnHLS.url", new_callable=PropertyMock)
+    def test_filmon_expired_stream(self, url, time):
+        stream = FilmOnHLS(self.session, channel="test")
+        url.return_value = "http://filmon.test.se/test.m3u8"
+        stream.watch_timeout = 0
+        time.return_value = 1
+
+        self.assertEqual(None, stream_to_url(stream))
+        self.assertRaises(TypeError, stream.to_url)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 4495b0c2..c8cd19dc 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,6 +1,7 @@
 import sys
 
 from streamlink.plugin.api.validate import xml_element, text
+from streamlink.utils import update_scheme
 
 try:
     import xml.etree.cElementTree as ET
@@ -79,3 +80,17 @@ class TestUtil(unittest.TestCase):
         self.assertEqual(
             {"test": "1", "foo": "bar"},
             parse_qsd("test=1&foo=bar", schema=validate.Schema({"test": validate.text, "foo": "bar"})))
+
+    def test_update_scheme(self):
+        self.assertEqual(
+            "https://example.com/foo",
+            update_scheme("https://other.com/bar", "//example.com/foo")
+        )
+        self.assertEqual(
+            "http://example.com/foo",
+            update_scheme("http://other.com/bar", "//example.com/foo")
+        )
+        self.assertEqual(
+            "http://example.com/foo",
+            update_scheme("https://other.com/bar", "http://example.com/foo")
+        )