Repository: streamlink/streamlink
Pull request #456 (2017-01-21 09:32:26)

Fix issue #426 on plugins/tga.py
Fix validation schema for longzhu.com in plugins/tga.py and standardize the returned stream name.  

codecov-io: ## [Current coverage](https://codecov.io/gh/streamlink/streamlink/pull/456?src=pr) is 47.28% (diff: 100%)
> Merging [#456](https://codecov.io/gh/streamlink/streamlink/pull/456?src=pr) into [master](https://codecov.io/gh/streamlink/streamlink/branch/master?src=pr) will not change coverage

```diff
@@             master       #456   diff @@
==========================================
  Files            58         58          
  Lines          4944       4944          
  Methods           0          0          
  Messages          0          0          
  Branches          0          0          
==========================================
  Hits           2338       2338          
  Misses         2606       2606          
  Partials          0          0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last update [c67bcf8...3b533a2](https://codecov.io/gh/streamlink/streamlink/compare/c67bcf8d90ced0a415a1d6b98877cdae6a575462...3b533a2c466b591e77942b304e6704b4d38ac71d?src=pr)
beardypig: Thanks for the fix. 

I just have a couple of notes about the stream names. The streams should be named for their quality not the streaming technology. The "live" streams should be called "live" (unless the quality is known) and the other streams should be the quality name.

If two streams have the same name then they are given as alternative streams in the UI. If a user has specified they don't want to use a particular streaming technology those are filtered out. Additionally, the "best" and "worst" streams cannot be determined with the appropriate names. 
beardypig: @fozzysec you should rebase instead of merge if you want to update your branch. 

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..cb10ff6d 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
@@ -106,6 +112,7 @@ nrk                 - tv.nrk.no          Yes   Yes   Streams may be geo-restrict
 oldlivestream       original.liv... [3]_ Yes   No    Only mobile streams are supported.
 openrectv           openrec.tv           Yes   Yes
 orf_tvthek          tvthek.orf.at        Yes   Yes
+ovvatv              ovva.tv              Yes   No
 pandatv             panda.tv             Yes   ?
 periscope           periscope.tv         Yes   Yes   Replay/VOD is supported.
 picarto             picarto.tv           Yes   --
@@ -115,6 +122,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
@@ -142,6 +150,7 @@ tf1                 - tf1.fr             Yes   No    Streams may be geo-restrict
                     - lci.fr
 tga                 - star.plu.cn        Yes   No
                     - star.tga.plu.cn
+                    - star.longzhu.com
 tigerdile           tigerdile.com        Yes   --
 trt                 trt.net.tr           Yes   No    Some streams may be geo-restricted to Turkey.
 trtspor             trtspor.com          Yes   No    Some streams are geo-restricted to Turkey.
@@ -160,6 +169,7 @@ tv8cat              tv8.cat              Yes   No    Streams may be geo-restrict
 tv360               tv360.com.tr         Yes   No
 tvcatchup           - tvcatchup.com      Yes   No    Streams may be geo-restricted to Great Britain.
 tvplayer            tvplayer.com         Yes   No    Streams may be geo-restricted to Great Britain. Premium streams are not supported.
+tvrplus             tvrplus.ro           Yes   No    Streams may be geo-restricted to Romania.
 twitch              twitch.tv            Yes   Yes   Possible to authenticate for access to
                                                      subscription streams.
 ustreamtv           ustream.tv           Yes   Yes   Currently broken.
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..7f1bf125 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")
@@ -34,8 +34,11 @@ if version_info[0] == 2:
     deps.append("backports.shutil_get_terminal_size")
 
 # for localization
-deps.append("iso-639")
-deps.append("iso3166")
+if environ.get("STREAMLINK_USE_PYCOUNTRY"):
+    deps.append("pycountry")
+else:
+    deps.append("iso-639")
+    deps.append("iso3166")
 
 # When we build an egg for the Win32 bootstrap we don't want dependency
 # information built into it.
@@ -46,7 +49,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 +68,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/ovvatv.py b/src/streamlink/plugins/ovvatv.py
new file mode 100644
index 00000000..e7b34669
--- /dev/null
+++ b/src/streamlink/plugins/ovvatv.py
@@ -0,0 +1,63 @@
+from __future__ import print_function
+import re
+import string
+from base64 import b64decode
+from pprint import pprint
+
+from streamlink import PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.compat import urlparse
+from streamlink.utils import parse_json
+
+
+class ovvaTV(Plugin):
+    url_re = re.compile(r"https?://(?:www\.)?ovva.tv/(?:ua/)?tvguide/.*?/online")
+    iframe_re = re.compile(r"iframe .*?src=\"((?:https?:)?//(?:\w+\.)?ovva.tv/[^\"]+)\"", re.DOTALL)
+    data_re = re.compile(r"ovva\(\'(.*?)\'\);")
+    ovva_data_schema = validate.Schema({
+        "url": validate.url()
+    }, validate.get("url"))
+    ovva_redirect_schema = validate.Schema(validate.all(
+        validate.transform(lambda x: x.split("=")),
+        ['302', validate.url()],
+        validate.get(1)
+    ))
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def find_iframe(self, res):
+        for url in self.iframe_re.findall(res.text):
+            if url.startswith("//"):
+                p = urlparse(self.url)
+                return "{0}:{1}".format(p.scheme, url)
+            else:
+                return url
+
+    def _get_streams(self):
+        http.headers = {"User-Agent": useragents.ANDROID}
+        res = http.get(self.url)
+        iframe_url = self.find_iframe(res)
+
+        if iframe_url:
+            self.logger.debug("Found iframe: {0}", iframe_url)
+            res = http.get(iframe_url, headers={"Referer": self.url})
+            data = self.data_re.search(res.text)
+            if data:
+                try:
+                    ovva_url = parse_json(b64decode(data.group(1)), schema=self.ovva_data_schema)
+                    stream_url = http.get(ovva_url, schema=self.ovva_redirect_schema)
+                except PluginError as e:
+                    self.logger.error("Could not find stream URL: {0}", e)
+                else:
+                    return HLSStream.parse_variant_playlist(self.session, stream_url)
+            else:
+                self.logger.error("Could not find player data.")
+
+
+__plugin__ = ovvaTV
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/tga.py b/src/streamlink/plugins/tga.py
index 2c665988..692a9bc6 100644
--- a/src/streamlink/plugins/tga.py
+++ b/src/streamlink/plugins/tga.py
@@ -9,8 +9,7 @@ from streamlink.stream import HLSStream, HTTPStream, RTMPStream
 
 CHANNEL_INFO_URL = "http://api.plu.cn/tga/streams/%s"
 QQ_STREAM_INFO_URL = "http://info.zb.qq.com/?cnlid=%d&cmd=2&stream=%d&system=1&sdtfrom=113"
-PLU_STREAM_INFO_URL = "http://star.api.plu.cn/live/GetLiveUrl?roomId=%d"
-
+PLU_STREAM_INFO_URL = "http://livestream.plu.cn/live/getlivePlayurl?roomId=%d"
 _quality_re = re.compile(r"\d+x(\d+)$")
 _url_re = re.compile(r"http://star\.longzhu\.(?:tv|com)/(m\/)?(?P<domain>[a-z0-9]+)")
 
@@ -18,10 +17,7 @@ _channel_schema = validate.Schema(
     {
         "data": validate.any(None, {
             "channel": validate.any(None, {
-                "id": validate.all(
-                    validate.text,
-                    validate.transform(int)
-                ),
+                "id": int,
                 "vid": int
             })
         })
@@ -31,11 +27,13 @@ _channel_schema = validate.Schema(
 
 _plu_schema = validate.Schema(
     {
-        "urls": [{
-            "securityUrl": validate.url(scheme=validate.any("rtmp", "http")),
-            "resolution": validate.text,
-            "ext": validate.text
-        }]
+        "playLines": [{
+            "urls": [{
+                "securityUrl": validate.url(scheme=validate.any("rtmp", "http")),
+                "resolution": validate.text,
+                "ext": validate.text
+                }]
+         }]
     }
 )
 
@@ -86,12 +84,12 @@ class Tga(Plugin):
 
         res = http.get(QQ_STREAM_INFO_URL % (vid, 2))
         info = http.json(res, schema=_qq_schema)
-        yield "live_http", HLSStream(self.session, info)
+        yield "live", HLSStream(self.session, info)
 
     def _get_plu_streams(self, cid):
         res = http.get(PLU_STREAM_INFO_URL % cid)
         info = http.json(res, schema=_plu_schema)
-        for source in info["urls"]:
+        for source in info["playLines"][0]["urls"]:
             quality = self._get_quality(source["resolution"])
             if source["ext"] == "m3u8":
                 yield quality, HLSStream(self.session, source["securityUrl"])
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/tvrplus.py b/src/streamlink/plugins/tvrplus.py
new file mode 100644
index 00000000..6015f323
--- /dev/null
+++ b/src/streamlink/plugins/tvrplus.py
@@ -0,0 +1,29 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+
+
+class TVRPlus(Plugin):
+    url_re = re.compile(r"https?://(?:www\.)tvrplus.ro/live-")
+    hls_file_re = re.compile(r"file: (?P<q>[\"'])(?P<url>http.+?m3u8.*?)(?P=q)")
+
+    stream_schema = validate.Schema(
+        validate.all(
+            validate.transform(hls_file_re.search),
+            validate.any(None, validate.get("url"))
+         ),
+    )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        stream_url = self.stream_schema.validate(http.get(self.url).text)
+        if stream_url:
+            return HLSStream.parse_variant_playlist(self.session, stream_url)
+
+__plugin__ = TVRPlus
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/http_server.py b/src/streamlink_cli/utils/http_server.py
index e4effe40..2e88db56 100644
--- a/src/streamlink_cli/utils/http_server.py
+++ b/src/streamlink_cli/utils/http_server.py
@@ -23,6 +23,7 @@ class HTTPRequest(BaseHTTPRequestHandler):
 class HTTPServer(object):
     def __init__(self):
         self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
         self.conn = self.host = self.port = None
         self.bound = False
 
@@ -111,4 +112,5 @@ class HTTPServer(object):
             self.conn.close()
 
         if not client_only:
+            self.socket.shutdown(2)
             self.socket.close()
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")
+        )