Repository: streamlink/streamlink
Pull request #772 (2017-04-06 18:15:59)

Improve contribution guidelines
I originally just wanted to quickly add some missing stuff to the issue template, but I thought it would be better to have real contribution guidelines. I've copied the one that I'm currently using in the Twitch GUI repo and made a couple of changes. I also added a known issues file, which is currently just linking to the website, but could be improved later on, if desired.

I don't know whether this will help reducing the number of duplicates or whether it will increase the quality of issues, since people usually don't read stuff on the internet, but I think that it will at least not hurt having something more strict. If it is too strict or if there are parts that should be rewritten/reworded or if there are spelling mistakes, let me know or edit the PR. 😃 


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 41e2325f..1d3919d5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,3 +1,99 @@
-Thank you for helping the Streamlink project! Before opening a new ticket,
-please make sure that there's no similar ticket already opened for your
-issue and that it isn't a [known issue](https://streamlink.github.io/players.html#known-issues-and-workarounds).
+# Contributing to Streamlink
+
+Want to get involved? Thanks! There are plenty of ways to help!
+
+
+## Reporting issues
+
+A bug is a *demonstrable problem* that is caused by the **latest version** of the code in the repository. Good bug reports are extremely helpful - thank you!
+
+Please read the following guidelines before you [report an issue][issues]:
+
+1. **See if the issue is already known** — check the list of [known issues][known-issues].
+
+2. **Use the GitHub issue search** — check if the issue has already been reported. If it has been, please comment on the existing issue.
+
+3. **Check if the issue has been fixed** — the latest `master` or development branch may already contain a fix.
+
+4. **Isolate the demonstrable problem** — make sure that the code in the project's repository is *definitely* responsible for the issue.
+
+5. **Format your issue report** — [well formatted texts][mastering-markdown] will make it much easier for developers to understand your report.
+
+Please try to be as detailed as possible in your report too. What is your environment? What steps will reproduce the issue? What would you expect the outcome to be? All these details will help people to assess and fix any potential bugs. The [issue template][issue-template] will aid you in structuring your report when submitting a new issue. If your report includes log output, please provide it as text in multi-line code blocks if possible for better readability. Thank you!
+
+
+## Feature requests
+
+Feature requests are welcome, but take a moment to find out whether your idea fits with the scope and aims of the project. It's up to *you* to make a strong case to convince the project's developers of the merits of this feature. Please provide as much detail and context as possible.
+
+
+## Plugin requests
+
+Plugin submissions and requests are a great way to improve Streamlink. Requests should be as detailed as possible and dedicated to only a single streaming service. Information about the service as well as explicit URLs for live streams or recordings are encouraged so that the requested plugin can be easily implemented. Please be aware that plugins for streaming services that are using DRM protections will not be implemented.
+
+
+## Pull requests
+
+Good pull requests - patches, improvements, and new features - are a fantastic help. They should remain focused in scope and avoid containing unrelated commits.
+
+**Please ask first** before embarking on any significant pull request (e.g. implementing features, refactoring code, porting to a different language), otherwise you risk spending a lot of time working on something that the project's developers might not want to merge into the project.
+
+Please adhere to the coding conventions used throughout a project (indentation, white space, accurate comments, etc.) and any other requirements (such as test coverage).
+
+Adhering to the following process is the best way to get your work included in the project:
+
+1. [Fork][howto-fork] the project, clone your fork, and configure the remotes:
+   ```bash
+   # Clone your fork of the repo into the current directory
+   git clone git@github.com:<YOUR-USERNAME>/streamlink.git
+   # Navigate to the newly cloned directory
+   cd streamlink
+   # Assign the original repo to a remote called "upstream"
+   git remote add upstream https://github.com/streamlink/streamlink.git
+   ```
+
+2. If you cloned a while ago, get the latest changes from upstream
+   ```bash
+   git checkout master
+   git pull upstream master
+   ```
+
+3. Create a new topic branch (off the main project branch) to contain your feature, change, or fix:
+   ```bash
+   git checkout -b <TOPIC-BRANCH-NAME>
+   ```
+
+4. Commit your changes in logical chunks. Please adhere to these [git commit message guidelines][howto-format-commits] or your code is unlikely be merged into the project. Use git's [interactive rebase][howto-rebase] feature to tidy up your commits before making them public.
+
+5. Locally merge (or rebase) the upstream branch into your topic branch:
+   ```bash
+   git pull [--rebase] upstream master
+   ```
+
+6. Push your topic branch up to your fork:
+   ```bash
+   git push origin <TOPIC-BRANCH-NAME>
+   ```
+
+7. [Open a Pull Request][howto-open-pull-requests] with a clear title and description.
+
+**IMPORTANT**: By submitting a patch, you agree to allow the project owners to license your work
+under the terms of the [BSD 2-clause license][license].
+
+
+## Acknowledgements
+
+This contributing guide has been adapted from [HTML5 boilerplate's guide][ref-h5bp].
+
+
+  [issues]: https://github.com/streamlink/streamlink/issues
+  [known-issues]: https://github.com/streamlink/streamlink/blob/master/KNOWN_ISSUES.md
+  [issue-template]: https://github.com/streamlink/streamlink/blob/master/ISSUE_TEMPLATE.md
+  [mastering-markdown]: https://guides.github.com/features/mastering-markdown
+  [howto-fork]: https://help.github.com/articles/fork-a-repo
+  [howto-rebase]: https://help.github.com/articles/interactive-rebase
+  [howto-format-commits]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
+  [howto-open-pull-requests]: https://help.github.com/articles/using-pull-requests
+  [Git]: https://git-scm.com
+  [license]: https://github.com/streamlink/streamlink/blob/master/LICENSE
+  [ref-h5bp]: https://github.com/h5bp/html5-boilerplate/blob/master/CONTRIBUTING.md
diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md
index 9ec38ad0..0fbbe343 100644
--- a/ISSUE_TEMPLATE.md
+++ b/ISSUE_TEMPLATE.md
@@ -1,7 +1,9 @@
 *Thanks for reporting an issue!*
-*Please read the contribution guidelines first!*
+*Please read the contribution guidelines first! (see the link above)*
+*Also check the list of known issues before reporting an issue!*
 
 *Feel free to use the following template. Be as detailed as possible.*
+*Please see the text preview to avoid unnecessary formatting errors.*
 *Don't forget to remove this text before submitting.*
 
 ----
@@ -9,9 +11,9 @@
 ### Checklist
 
 - [x] This is a bug report.
-- [ ] This is a plugin request.
 - [ ] This is a feature request.
-- [ ] I used the search function to find already opened/closed issues or pull requests.
+- [ ] This is a plugin (improvement) request.
+- [ ] I have read the contribution guidelines.
 
 ### Description
 
@@ -21,13 +23,16 @@
 
 ...
 
-### Reproduction steps / Stream URLs to test
+### Reproduction steps / Explicit stream URLs to test
 
 1. ...
 2. ...
 3. ...
 
-### Environment details (operating system, python version, etc.)
+### Environment details
+
+Operating system and version: ...
+Streamlink and Python version: ...
 
 ...
 
diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md
new file mode 100644
index 00000000..c558dd2a
--- /dev/null
+++ b/KNOWN_ISSUES.md
@@ -0,0 +1,4 @@
+# Known issues
+
+For a detailed list of known issues, see here:
+https://streamlink.github.io/players.html#known-issues-and-workarounds
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 732ee086..716038e9 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -31,6 +31,8 @@ bambuser            bambuser.com         Yes   Yes
 bbciplayer          bbc.co.uk/iplayer    Yes   Yes   Streams may be geo-restricted to the United Kingdom.
 beam                beam.pro             Yes   Yes
 beattv              be-at.tv             Yes   Yes   Playlist not implemented yet.
+bfmtv               bfmtv.com            Yes   Yes
+                    01net.com
 bigo                - live.bigo.tv       Yes   --
                     - bigoweb.co
 bilibili            live.bilibili.com    Yes   ?
@@ -119,6 +121,8 @@ mediaklikk          mediaklikk.hu        Yes   No    Streams may be geo-restrict
 mips                mips.tv              Yes   --    Requires rtmpdump with K-S-V patches.
 mitele              mitele.es            Yes   No    Streams may be geo-restricted to Spain.
 mlgtv               mlg.tv               Yes   --
+nbc                 nbc.com              No    Yes   Streams are geo-restricted to USA. Authentication is not supported.
+nbcsports           nbcsports.com        No    Yes   Streams maybe be geo-restricted to USA. Authentication is not supported.
 nhkworld            nhk.or.jp/nhkworld   Yes   No
 nineanime           9anime.to            --    Yes
 nos                 nos.nl               Yes   Yes   Streams may be geo-restricted to Netherlands.
@@ -132,6 +136,7 @@ openrectv           openrec.tv           Yes   Yes
 orf_tvthek          tvthek.orf.at        Yes   Yes
 ovvatv              ovva.tv              Yes   No
 pandatv             panda.tv             Yes   ?
+pcyourfreetv        pc-yourfreetv.com    Yes   --    Requires a login.
 periscope           periscope.tv         Yes   Yes   Replay/VOD is supported.
 picarto             picarto.tv           Yes   --
 playtv              playtv.fr            Yes   --    Streams may be geo-restricted to France.
@@ -173,6 +178,7 @@ tf1                 - tf1.fr             Yes   No    Streams may be geo-restrict
 tga                 - star.plu.cn        Yes   No
                     - star.tga.plu.cn
                     - star.longzhu.com
+theplatform         player.thepl... [7]_ No    Yes
 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.
@@ -233,3 +239,4 @@ zhanqitv            zhanqi.tv            Yes   No
 .. [4] streaming.media.ccc.de
 .. [5] mediathek.daserste.de
 .. [6] players.brightcove.net
+.. [7] player.theplatform.com
diff --git a/src/streamlink/plugins/bfmtv.py b/src/streamlink/plugins/bfmtv.py
new file mode 100644
index 00000000..8b910d2a
--- /dev/null
+++ b/src/streamlink/plugins/bfmtv.py
@@ -0,0 +1,39 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugins.brightcove import BrightcovePlayer
+from streamlink.stream import HLSStream
+
+
+class BFMTV(Plugin):
+    _url_re = re.compile(r'http://.+\.(?:bfmtv|01net)\.com')
+    _brightcove_video_re = re.compile(r'data-holder="video(?P<video_id>[0-9]+)" data-account="(?P<account_id>[0-9]+)"')
+    _brightcove_video_alt_re = re.compile(r'data-account="(?P<account_id>[0-9]+).*?data-video-id="(?P<video_id>[0-9]+)"')
+    _embed_video_url_re = re.compile(r"\$YOPLAYER\('liveStitching', {.+?file: '(?P<video_url>[^\"]+?)'.+?}\);", re.DOTALL)
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return BFMTV._url_re.match(url)
+
+    def _get_streams(self):
+        # Retrieve URL page and search for Brightcove video data
+        res = http.get(self.url)
+        match = self._brightcove_video_re.search(res.text) or self._brightcove_video_alt_re.search(res.text)
+        if match is not None:
+            account_id = match.group('account_id')
+            video_id = match.group('video_id')
+            player = BrightcovePlayer(self.session, account_id)
+            for stream in player.get_streams(video_id):
+                yield stream
+        else:
+            # Try to get the stream URL in the page
+            match = self._embed_video_url_re.search(res.text)
+            if match is not None:
+                video_url = match.group('video_url')
+                if '.m3u8' in video_url:
+                    for stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
+                        yield stream
+
+
+__plugin__ = BFMTV
diff --git a/src/streamlink/plugins/brightcove.py b/src/streamlink/plugins/brightcove.py
index 7fa1ce19..5d9b0015 100644
--- a/src/streamlink/plugins/brightcove.py
+++ b/src/streamlink/plugins/brightcove.py
@@ -18,7 +18,10 @@ class BrightcovePlayer(object):
             validate.optional("height"): validate.any(int, None),
             validate.optional("avg_bitrate"): validate.any(int, None),
             validate.optional("src"): validate.url(),
-            validate.optional("app_name"): validate.url(scheme="rtmp"),
+            validate.optional("app_name"): validate.any(
+                validate.url(scheme="rtmp"),
+                validate.url(scheme="rtmpe")
+            ),
             validate.optional("stream_name"): validate.text,
             validate.optional("type"): validate.text
         }]
@@ -73,7 +76,7 @@ class BrightcovePlayer(object):
                 q = "live"
 
             if ((source.get("type") == "application/x-mpegURL" and source.get("src")) or
-                    (source.get("src") and source.get("src").endswith(".m3u8"))):
+                    (source.get("src") and ".m3u8" in source.get("src"))):
                 for s in HLSStream.parse_variant_playlist(self.session, source.get("src")).items():
                     yield s
             elif source.get("app_name"):
diff --git a/src/streamlink/plugins/canalplus.py b/src/streamlink/plugins/canalplus.py
index ee06516d..3d0f1b4b 100644
--- a/src/streamlink/plugins/canalplus.py
+++ b/src/streamlink/plugins/canalplus.py
@@ -23,6 +23,7 @@ class CanalPlus(Plugin):
     _video_id_re = re.compile(r'\bdata-video="(?P<video_id>[0-9]+)"')
     _mp4_bitrate_re = re.compile(r'.*_(?P<bitrate>[0-9]+k)\.mp4')
     _api_schema = validate.Schema({
+        'TYPE': validate.text,
         'MEDIA': validate.Schema({
             'VIDEOS': validate.Schema({
                 validate.text: validate.any(
@@ -73,7 +74,8 @@ class CanalPlus(Plugin):
             parsed.append(video_url)
 
             try:
-                if '.f4m' in video_url:
+                # HDS streams don't seem to work for live videos
+                if '.f4m' in video_url and videos['TYPE'] != 'CHAINE LIVE':
                     for stream in HDSStream.parse_manifest(self.session,
                                                            video_url,
                                                            params={'hdcore': self.HDCORE_VERSION},
diff --git a/src/streamlink/plugins/chaturbate.py b/src/streamlink/plugins/chaturbate.py
index 1e59482b..4820db3a 100644
--- a/src/streamlink/plugins/chaturbate.py
+++ b/src/streamlink/plugins/chaturbate.py
@@ -1,37 +1,53 @@
 import re
+import uuid
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import http, validate
+from streamlink.plugin.api import http
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
 
-_url_re = re.compile(r"http(s)?://(\w+.)?chaturbate.com/[^/?&]+")
-_playlist_url_re = re.compile(r"var hlsSource\w+ = '(?P<url>[^']+)';")
-_schema = validate.Schema(
-    validate.transform(_playlist_url_re.search),
-    validate.any(
-        None,
-        validate.all(
-            validate.get("url"),
-            validate.url(
-                scheme="http",
-                path=validate.endswith(".m3u8")
-            )
-        )
-    )
+API_HLS = "https://chaturbate.com/get_edge_hls_url_ajax/"
+
+_url_re = re.compile(r"https?://(\w+\.)?chaturbate\.com/(?P<username>\w+)")
+
+_post_schema = validate.Schema(
+    {
+        "url": validate.text,
+        "room_status": validate.text,
+        "success": int
+    }
 )
 
 
 class Chaturbate(Plugin):
     @classmethod
-    def can_handle_url(self, url):
+    def can_handle_url(cls, url):
         return _url_re.match(url)
 
     def _get_streams(self):
-        playlist_url = http.get(self.url, schema=_schema)
-        if not playlist_url:
-            return
+        match = _url_re.match(self.url)
+        username = match.group("username")
+
+        CSRFToken = str(uuid.uuid4().hex.upper()[0:32])
+
+        headers = {
+            "Content-Type": "application/x-www-form-urlencoded",
+            "X-CSRFToken": CSRFToken,
+            "X-Requested-With": "XMLHttpRequest",
+            "Referer": self.url,
+        }
+
+        cookies = {
+            "csrftoken": CSRFToken,
+        }
+
+        post_data = "room_slug={0}&bandwidth=high".format(username)
 
-        return HLSStream.parse_variant_playlist(self.session, playlist_url)
+        res = http.post(API_HLS, headers=headers, cookies=cookies, data=post_data)
+        data = http.json(res, schema=_post_schema)
 
+        if data["success"] is True and data["room_status"] == "public":
+            for s in HLSStream.parse_variant_playlist(self.session, data["url"]).items():
+                yield s
 
 __plugin__ = Chaturbate
diff --git a/src/streamlink/plugins/dailymotion.py b/src/streamlink/plugins/dailymotion.py
index bbf6cad0..c61c2dec 100644
--- a/src/streamlink/plugins/dailymotion.py
+++ b/src/streamlink/plugins/dailymotion.py
@@ -1,3 +1,4 @@
+import json
 import re
 
 from functools import reduce
@@ -23,6 +24,7 @@ QUALITY_MAP = {
     "source": "hds"
 }
 STREAM_INFO_URL = "http://www.dailymotion.com/sequence/full/{0}"
+USER_INFO_URL = "https://api.dailymotion.com/user/{0}"
 
 _rtmp_re = re.compile(r"""
     (?P<host>rtmp://[^/]+)
@@ -39,7 +41,8 @@ _url_re = re.compile(r"""
         /(?P<channel_name>[A-Za-z0-9-_]+)
     )
 """, re.VERBOSE)
-featured_re = re.compile(r"""user_feature_video_id"\s*:\s*(\d+)""")
+username_re = re.compile(r'''data-username\s*=\s*"(.*?)"''')
+chromecast_re = re.compile(r'''stream_chromecast_url"\s*:\s*(?P<url>".*?")''')
 
 _media_inner_schema = validate.Schema([{
     "layerList": [{
@@ -196,18 +199,45 @@ class DailyMotion(Plugin):
                     stream = self._create_flv_playlist(failover)
                     yield name, stream
 
+    def _chrome_cast_stream_fallback(self):
+        self.logger.debug("Trying to find Chromecast URL as a fallback")
+        # get the page if not already available
+        page = http.get(self.url, cookies=COOKIES)
+        m = chromecast_re.search(page.text)
+        if m:
+            url = json.loads(m.group("url"))
+            return HLSStream.parse_variant_playlist(self.session, url)
+
+    def get_featured_video(self):
+        self.logger.debug("Channel page, attempting to play featured video")
+        page = http.get(self.url, cookies=COOKIES)
+        username_m = username_re.search(page.text)
+        username = username_m and username_m.group(1)
+        if username:
+            self.logger.debug("Found username: {0}", username)
+            res = http.get(USER_INFO_URL.format(username),
+                           params={"fields": "videostar.url"})
+
+            data = http.json(res)
+            if "videostar.url" in data and self.can_handle_url(data["videostar.url"]):
+                return data["videostar.url"]
+
     def _get_streams(self):
         match = _url_re.match(self.url)
         media_id = match.group("media_id")
+
         if not media_id and match.group("channel_name"):
-            self.logger.debug("Channel page, attempting to play featured video")
-            res = http.get(self.url)
-            media_id_m = featured_re.search(res.text)
-            media_id = media_id_m and media_id_m.group(1)
+            self.url = self.get_featured_video()
+            match = _url_re.match(self.url)
+            media_id = match.group("media_id")
 
         if media_id:
             self.logger.debug("Found media ID: {0}", media_id)
-            return self._get_streams_from_media(media_id)
+            streams = list(self._get_streams_from_media(media_id))
+            if streams:
+                return streams
+
+        return self._chrome_cast_stream_fallback()
 
 
 __plugin__ = DailyMotion
diff --git a/src/streamlink/plugins/nbc.py b/src/streamlink/plugins/nbc.py
new file mode 100644
index 00000000..defa227e
--- /dev/null
+++ b/src/streamlink/plugins/nbc.py
@@ -0,0 +1,30 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugins.theplatform import ThePlatform
+from streamlink.utils import update_scheme
+
+
+class NBC(Plugin):
+    url_re = re.compile(r"https?://(?:www\.)?nbc\.com")
+    embed_url_re = re.compile(r'''(?P<q>["'])embedURL(?P=q)\s*:\s*(?P<q2>["'])(?P<url>.*?)(?P=q2)''')
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        res = http.get(self.url)
+        m = self.embed_url_re.search(res.text)
+        platform_url = m and m.group("url")
+
+        if platform_url:
+            url = update_scheme(self.url, platform_url)
+            # hand off to ThePlatform plugin
+            p = ThePlatform(url)
+            p.bind(self.session, "plugin.nbc")
+            return p.streams()
+
+
+__plugin__ = NBC
diff --git a/src/streamlink/plugins/nbcsports.py b/src/streamlink/plugins/nbcsports.py
new file mode 100644
index 00000000..ed23533b
--- /dev/null
+++ b/src/streamlink/plugins/nbcsports.py
@@ -0,0 +1,30 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugins.theplatform import ThePlatform
+from streamlink.utils import update_scheme
+
+
+class NBCSports(Plugin):
+    url_re = re.compile(r"https?://(?:www\.)?nbcsports\.com")
+    embed_url_re = re.compile(r'''id\s*=\s*"vod-player"\s+src\s*=\s*"(?P<url>.*?)"''')
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        res = http.get(self.url)
+        m = self.embed_url_re.search(res.text)
+        platform_url = m and m.group("url")
+
+        if platform_url:
+            url = update_scheme(self.url, platform_url)
+            # hand off to ThePlatform plugin
+            p = ThePlatform(url)
+            p.bind(self.session, "plugin.nbcsports")
+            return p.streams()
+
+
+__plugin__ = NBCSports
diff --git a/src/streamlink/plugins/pcyourfreetv.py b/src/streamlink/plugins/pcyourfreetv.py
new file mode 100644
index 00000000..912a31c8
--- /dev/null
+++ b/src/streamlink/plugins/pcyourfreetv.py
@@ -0,0 +1,62 @@
+import re
+
+from streamlink.plugin import Plugin, PluginOptions
+from streamlink.plugin.api import http
+from streamlink.stream import HLSStream
+
+
+class PCYourFreeTV(Plugin):
+    _login_url = 'http://pc-yourfreetv.com/home.php'
+    _url_re = re.compile(r'http://pc-yourfreetv\.com/index_player\.php\?channel=.+?&page_id=\d+')
+    _video_url_re = re.compile(r"jwplayer\('.+?'\)\.setup\({.+?file: \"(?P<video_url>[^\"]+?)\".+?}\);", re.DOTALL)
+
+    options = PluginOptions({
+        'username': None,
+        'password': None
+    })
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return PCYourFreeTV._url_re.match(url)
+
+    def login(self, username, password):
+        res = http.post(
+            self._login_url,
+            data={
+                'user_name': username,
+                'user_pass': password,
+                'login': 'Login'
+            }
+        )
+
+        return username in res.text
+
+    def _get_streams(self):
+        username = self.get_option('username')
+        password = self.get_option('password')
+
+        if username is None or password is None:
+            self.logger.error("PC-YourFreeTV requires authentication, use --pcyourfreetv-username"
+                              "and --pcyourfreetv-password to set your username/password combination")
+            return
+
+        if self.login(username, password):
+            self.logger.info("Successfully logged in as {0}", username)
+
+        # Retrieve URL page and search for stream data
+        res = http.get(self.url)
+        match = self._video_url_re.search(res.text)
+        if match is None:
+            return
+        video_url = match.group('video_url')
+        if '.m3u8' in video_url:
+            streams = HLSStream.parse_variant_playlist(self.session, video_url)
+            if len(streams) != 0:
+                for stream in streams.items():
+                    yield stream
+            else:
+                # Not a HLS playlist
+                yield 'live', HLSStream(self.session, video_url)
+
+
+__plugin__ = PCYourFreeTV
diff --git a/src/streamlink/plugins/playtv.py b/src/streamlink/plugins/playtv.py
index 8df9b75f..d046d9aa 100644
--- a/src/streamlink/plugins/playtv.py
+++ b/src/streamlink/plugins/playtv.py
@@ -48,8 +48,8 @@ class PlayTV(Plugin):
         for language in streams:
             for protocol, bitrates in list(streams[language].items()):
                 # - Ignore non-supported protocols (RTSP, DASH)
-                # - Ignore deprecated Flash (RTMPE) streams (PlayTV doesn't provide anymore a Flash player)
-                if protocol in ['rtsp', 'flash', 'dash']:
+                # - Ignore deprecated Flash (RTMPE/HDS) streams (PlayTV doesn't provide anymore a Flash player)
+                if protocol in ['rtsp', 'flash', 'dash', 'hds']:
                     continue
 
                 for bitrate in bitrates['bitrates']:
diff --git a/src/streamlink/plugins/theplatform.py b/src/streamlink/plugins/theplatform.py
new file mode 100644
index 00000000..120265f9
--- /dev/null
+++ b/src/streamlink/plugins/theplatform.py
@@ -0,0 +1,39 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.stream import HLSStream
+
+
+class ThePlatform(Plugin):
+    """
+    Plugin to support streaming videos hosted by thePlatform
+    """
+    url_re = re.compile(r"https?://player.theplatform.com/p/")
+    release_re = re.compile(r'''tp:releaseUrl\s*=\s*"(.*?)"''')
+    video_src_re = re.compile(r'''video.*?src="(.*?)"''')
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        res = http.get(self.url)
+        m = self.release_re.search(res.text)
+        release_url = m and m.group(1)
+        if release_url:
+            api_url = release_url+"&formats=m3u,mpeg4"
+            res = http.get(api_url, allow_redirects=False, raise_for_status=False)
+            if res.status_code == 302:
+                stream_url = res.headers.get("Location")
+                return HLSStream.parse_variant_playlist(self.session, stream_url, headers={
+                    "Referer": self.url
+                })
+            else:
+                error = http.json(res)
+                self.logger.error("{0}: {1}",
+                                  error.get("title", "Error"),
+                                  error.get("description", "An unknown error occurred"))
+
+
+__plugin__ = ThePlatform
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 47cc583a..80440d22 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -247,7 +247,7 @@ class TwitchAPI(object):
         return self.call_subdomain("tmi", "/hosts", format="", **params)
 
     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)
+        return http.json(self.call_subdomain("clips", "/api/v2/clips/" + clip_name + "/status", format=""), schema=schema)
 
     # Unsupported/Removed private API calls
 
@@ -282,19 +282,36 @@ class Twitch(Plugin):
 
     def __init__(self, url):
         Plugin.__init__(self, url)
-        match = _url_re.match(url).groupdict()
-        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")
-        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 = []
-
+        match = _url_re.match(url).groupdict()
         parsed = urlparse(url)
         self.params = parse_query(parsed.query)
+        self.subdomain = match.get("subdomain")
+        self.video_id = None
+        self.video_type = None
+        self._channel_id = None
+        self._channel = None
+        self.clip_name = None
+
+        if self.subdomain == "player":
+            # pop-out player
+            if self.params.get("video"):
+                try:
+                    self.video_type = self.params["video"][0]
+                    self.video_id = self.params["video"][1:]
+                except IndexError:
+                    self.logger.debug("Invalid video param: {0}", self.params["video"])
+            self._channel = self.params.get("channel")
+        elif self.subdomain == "clips":
+            # clip share URL
+            self.clip_name = match.get("channel")
+        else:
+            self._channel = match.get("channel") and match.get("channel").lower()
+            self.video_type = match.get("video_type")
+            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.api = TwitchAPI(beta=self.subdomain == "beta", version=5)
         self.usher = UsherService()
@@ -596,7 +613,7 @@ class Twitch(Plugin):
                 return self._get_video_streams()
         elif self.clip_name:
             return self._get_clips()
-        else:
+        elif self._channel:
             return self._get_hls_streams("live")
 
 
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 49e4eafd..c9b57e7d 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -1204,6 +1204,20 @@ plugin.add_argument(
     A LiveEdu account password to use with --liveedu-email.
     """
 )
+plugin.add_argument(
+    "--pcyourfreetv-username",
+    metavar="USERNAME",
+    help="""
+    The username used to register with pc-yourfreetv.com.
+    """
+)
+plugin.add_argument(
+    "--pcyourfreetv-password",
+    metavar="PASSWORD",
+    help="""
+    A pc-yourfreetv.com account password to use with --pcyourfreetv-username.
+    """
+)
 
 # Deprecated options
 stream.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index dffbfc23..c1b1997d 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -244,14 +244,17 @@ def output_stream(stream):
     """Open stream, create output and finally write the stream to output."""
     global output
 
+    success_open = False
     for i in range(args.retry_open):
         try:
             stream_fd, prebuffer = open_stream(stream)
+            success_open = True
             break
         except StreamError as err:
-            console.logger.error("{0}", err)
-    else:
-        return
+            console.logger.error("Try {0}/{1}: Could not open stream {2} ({3})", i+1, args.retry_open, stream, err)
+
+    if not success_open:
+        console.exit("Could not open stream {0}, tried {1} times, exiting", stream, args.retry_open)
 
     output = create_output()
 
@@ -307,14 +310,14 @@ def read_stream(stream, output, prebuffer, chunk_size=8192):
                 elif is_http and err.errno in ACCEPTABLE_ERRNO:
                     console.logger.info("HTTP connection closed")
                 else:
-                    console.logger.error("Error when writing to output: {0}", err)
+                    console.exit("Error when writing to output: {0}, exiting", err)
 
                 break
     except IOError as err:
-        console.logger.error("Error when reading from stream: {0}", err)
-
-    stream.close()
-    console.logger.info("Stream ended")
+        console.exit("Error when reading from stream: {0}, exiting", err)
+    finally:
+        stream.close()
+        console.logger.info("Stream ended")
 
 
 def handle_stream(plugin, streams, stream_name):
@@ -913,6 +916,17 @@ def setup_plugin_options():
     if liveedu_password:
         streamlink.set_plugin_option("liveedu", "password", liveedu_password)
 
+    if args.pcyourfreetv_username:
+        streamlink.set_plugin_option("pcyourfreetv", "username", args.pcyourfreetv_username)
+
+    if args.pcyourfreetv_username and not args.pcyourfreetv_password:
+        pcyourfreetv_password = console.askpass("Enter pc-yourfreetv.com password: ")
+    else:
+        pcyourfreetv_password = args.pcyourfreetv_password
+
+    if pcyourfreetv_password:
+        streamlink.set_plugin_option("pcyourfreetv", "password", pcyourfreetv_password)
+
     # Deprecated options
     if args.jtv_legacy_names:
         console.logger.warning("The option --jtv/twitch-legacy-names is "


Test patch:
diff --git a/tests/test_plugin_bfmtv.py b/tests/test_plugin_bfmtv.py
new file mode 100644
index 00000000..38fa226a
--- /dev/null
+++ b/tests/test_plugin_bfmtv.py
@@ -0,0 +1,25 @@
+import unittest
+
+from streamlink.plugins.bfmtv import BFMTV
+
+
+class TestPluginBFMTV(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(BFMTV.can_handle_url("http://www.bfmtv.com/mediaplayer/live-video/"))
+        self.assertTrue(BFMTV.can_handle_url("http://bfmbusiness.bfmtv.com/mediaplayer/live-video/"))
+        self.assertTrue(BFMTV.can_handle_url("http://www.bfmtv.com/mediaplayer/live-bfm-paris/"))
+        self.assertTrue(BFMTV.can_handle_url("http://rmc.bfmtv.com/mediaplayer/live-audio/"))
+        self.assertTrue(BFMTV.can_handle_url("http://rmcsport.bfmtv.com/mediaplayer/live-bfm-sport/"))
+        self.assertTrue(BFMTV.can_handle_url("http://rmcdecouverte.bfmtv.com/mediaplayer-direct/"))
+        self.assertTrue(BFMTV.can_handle_url("http://www.bfmtv.com/mediaplayer/replay/premiere-edition/"))
+        self.assertTrue(BFMTV.can_handle_url("http://bfmbusiness.bfmtv.com/mediaplayer/replay/good-morning-business/"))
+        self.assertTrue(BFMTV.can_handle_url("http://rmc.bfmtv.com/mediaplayer/replay/les-grandes-gueules/"))
+        self.assertTrue(BFMTV.can_handle_url("http://rmcdecouverte.bfmtv.com/mediaplayer-replay/?id=6714&title=TOP%20GEAR%20:PASSION%20VINTAGE"))
+        self.assertTrue(BFMTV.can_handle_url("http://rmc.bfmtv.com/mediaplayer/replay/after-foot/"))
+        self.assertTrue(BFMTV.can_handle_url("http://www.01net.com/mediaplayer/replay/jtech/"))
+        self.assertTrue(BFMTV.can_handle_url("http://www.bfmtv.com/politique/macron-et-le-pen-talonnes-par-fillon-et-melenchon-a-l-approche-du-premier-tour-1142070.html"))
+
+        # shouldn't match
+        self.assertFalse(BFMTV.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(BFMTV.can_handle_url("http://www.youtube.com/"))
diff --git a/tests/test_plugin_chaturbate.py b/tests/test_plugin_chaturbate.py
new file mode 100644
index 00000000..a9542a33
--- /dev/null
+++ b/tests/test_plugin_chaturbate.py
@@ -0,0 +1,15 @@
+import unittest
+
+from streamlink.plugins.chaturbate import Chaturbate
+
+
+class TestPluginChaturbate(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(Chaturbate.can_handle_url("https://chaturbate.com/username"))
+        self.assertTrue(Chaturbate.can_handle_url("https://m.chaturbate.com/username"))
+        self.assertTrue(Chaturbate.can_handle_url("https://www.chaturbate.com/username"))
+
+        # shouldn't match
+        self.assertFalse(Chaturbate.can_handle_url("http://local.local/"))
+        self.assertFalse(Chaturbate.can_handle_url("http://localhost.localhost/"))
diff --git a/tests/test_plugin_pcyourfreetv.py b/tests/test_plugin_pcyourfreetv.py
new file mode 100644
index 00000000..d1ee96dc
--- /dev/null
+++ b/tests/test_plugin_pcyourfreetv.py
@@ -0,0 +1,18 @@
+import unittest
+
+from streamlink.plugins.pcyourfreetv import PCYourFreeTV
+
+
+class TestPluginPCYourFreeTV(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_player.php?channel=das%20erste&page_id=41"))
+        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_player.php?channel=srf%20eins&page_id=41"))
+        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_player.php?channel=bbc%20one&page_id=41"))
+        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_player.php?channel=tf1&page_id=41"))
+
+        # shouldn't match
+        self.assertFalse(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/home.php"))
+        self.assertFalse(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_livetv.php?page_id=1"))
+        self.assertFalse(PCYourFreeTV.can_handle_url("http://tvcatchup.com/"))
+        self.assertFalse(PCYourFreeTV.can_handle_url("http://youtube.com/"))