Repository: streamlink/streamlink

Issue:
Playtv uk
playtv.fr working fine is there any possibility to run http://uk.play.tv/    looks like both sites france and uk owned by same  company. As streamlink support tvplay.fr. hope u get support for uk playtv.
http://uk.play.tv/live-tv/363/pick-tv/
http://uk.play.tv/live-tv/752/itv4-1/
http://uk.play.tv/live-tv/1106/itv3-1/
http://uk.play.tv/live-tv/1105/itv-1/

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/2388?src=pr&el=h1) Report
> Merging [#2388](https://codecov.io/gh/streamlink/streamlink/pull/2388?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/bf8580f977c34880b45cb4b9b3d5b37d641cfc36?src=pr&el=desc) will **decrease** coverage by `0.32%`.
> The diff coverage is `83.33%`.


```diff
@@            Coverage Diff             @@
##           master    #2388      +/-   ##
==========================================
- Coverage   52.64%   52.31%   -0.33%     
==========================================
  Files         238      238              
  Lines       14961    14962       +1     
==========================================
- Hits         7876     7828      -48     
- Misses       7085     7134      +49
```

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


```diff
@@            Coverage Diff             @@
##           master    #2388      +/-   ##
==========================================
- Coverage   52.64%   52.31%   -0.33%     
==========================================
  Files         238      238              
  Lines       14961    14962       +1     
==========================================
- Hits         7876     7828      -48     
- Misses       7085     7134      +49
```

beardypig: Just to note that there probably need to be some changes for the windows installer too :)

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 2363fc1e..b88febc7 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -16,6 +16,9 @@ abematv                 abema.tv             Yes   Yes   Streams are geo-restric
 abweb                   abweb.com            Yes   No    Requires a login and a subscription.
 adultswim               adultswim.com        Yes   Yes   Streams may be geo-restricted, some VOD streams are protected by DRM.
 afreeca                 play.afreecatv.com   Yes   No
+albavision              - tvc.com.ec         Yes   No    Some streams are geo-restricted.
+                        - rts.com.ec
+                        - elnueve.com.ar
 aljazeeraen             aljazeera.com        Yes   Yes   English version of the site.
 animelab                animelab.com         --    Yes   Requires a login. Streams may be geo-restricted to Australia and New Zealand.
 app17                   17app.co             Yes   --
@@ -240,6 +243,7 @@ tvplayer                tvplayer.com         Yes   No    Streams may be geo-rest
 tvrby                   tvr.by               Yes   No    Streams may be geo-restricted to Belarus.
 tvrplus                 tvrplus.ro           Yes   No    Streams may be geo-restricted to Romania.
 tvtoya                  tvtoya.pl            Yes   --
+twitcasting             twitcasting.tv       Yes   No
 twitch                  twitch.tv            Yes   Yes   Possible to authenticate for access to
                                                          subscription streams.
 ustreamtv               ustream.tv           Yes   Yes
diff --git a/src/streamlink/plugins/albavision.py b/src/streamlink/plugins/albavision.py
new file mode 100644
index 00000000..6d8d4625
--- /dev/null
+++ b/src/streamlink/plugins/albavision.py
@@ -0,0 +1,117 @@
+"""
+Support for the live streams on Albavision sites
+    - http://www.tvc.com.ec
+    - http://www.rts.com.ec
+    - https://www.elnueve.com.ar
+"""
+import logging
+import re
+import time
+
+from streamlink import PluginError
+from streamlink.compat import quote, urlencode
+from streamlink.plugin import Plugin
+from streamlink.plugin.api.utils import itertags
+from streamlink.stream import HLSStream
+from streamlink.utils import update_scheme
+
+log = logging.getLogger(__name__)
+
+
+class Albavision(Plugin):
+    _url_re = re.compile(r"https?://(?:www\.)?(tvc.com.ec|rts.com.ec|elnueve.com.ar)/en-?vivo")
+    _token_input_re = re.compile(r"Math.floor\(Date.now\(\) / 3600000\),'([a-f0-9OK]+)'")
+    _live_url_re = re.compile(r"LIVE_URL = '(.*?)';")
+    _playlist_re = re.compile(r"file:\s*'(http.*m3u8)'")
+    _token_url_re = re.compile(r"https://.*/token/.*?\?rsk=")
+
+    _channel_urls = {
+        'Quito': 'http://d3aacg6baj4jn0.cloudfront.net/reproductor_rts_o_quito.html?iut=',
+        'Guayaquil': 'http://d2a6tcnofawcbm.cloudfront.net/player_rts.html?iut=',
+        'Canal5': 'http://dxejh4fchgs18.cloudfront.net/player_televicentro.html?iut='
+    }
+
+    def __init__(self, url):
+        super(Albavision, self).__init__(url)
+        self._page = None
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    @property
+    def page(self):
+        if not self._page:
+            self._page = self.session.http.get(self.url)
+        return self._page
+
+    def _get_token_url(self):
+        token = self._get_live_url_token()
+        if token:
+            m = self._token_url_re.search(self.page.text)
+            token_url = m and m.group(0)
+            if token_url:
+                log.debug("token_url={0}{1}".format(token_url, token))
+                return token_url + token
+        else:
+            log.error("Could not find site token")
+
+    @staticmethod
+    def transform_token(token_in, date):
+        token_out = list(token_in)
+        offset = len(token_in)
+        for i in range(offset - 1, -1, -1):
+            p = (i * date) % offset
+            # swap chars at p and i
+            token_out[i], token_out[p] = token_out[p], token_out[i]
+        token_out = ''.join(token_out)
+        if token_out.endswith("OK"):
+            return token_out[:-2]  # return token without OK suffix
+        else:
+            log.error("Invalid site token: {0} => {1}".format(token_in, token_out))
+
+    def _get_live_url_token(self):
+        m = self._token_input_re.search(self.page.text)
+        if m:
+            date = int(time.time()//3600)
+            return self.transform_token(m.group(1), date) or self.transform_token(m.group(1), date - 1)
+
+    def _get_token(self):
+        token_url = self._get_token_url()
+        if token_url:
+            res = self.session.http.get(token_url)
+            data = self.session.http.json(res)
+            if data['success']:
+                return data['token']
+
+    def _get_streams(self):
+        m = self._live_url_re.search(self.page.text)
+        playlist_url = m and update_scheme(self.url, m.group(1))
+        player_url = self.url
+        token = self._get_token()
+
+        if playlist_url:
+            log.debug("Found playlist URL in the page")
+        else:
+            live_channel = None
+            for div in itertags(self.page.text, "div"):
+                if div.attributes.get("id") == "botonLive":
+                    live_channel = div.attributes.get("data-canal")
+
+            if live_channel:
+                log.debug("Live channel: {0}".format(live_channel))
+                player_url = self._channel_urls[live_channel]+quote(token)
+                page = self.session.http.get(player_url, raise_for_status=False)
+                if "block access from your country." in page.text:
+                    raise PluginError("Content is geo-locked")
+                m = self._playlist_re.search(page.text)
+                playlist_url = m and update_scheme(self.url, m.group(1))
+            else:
+                log.error("Could not find the live channel")
+
+        if playlist_url:
+            stream_url = "{0}?{1}".format(playlist_url, urlencode({"iut": token}))
+            return HLSStream.parse_variant_playlist(self.session, stream_url, headers={"referer": player_url})
+
+
+__plugin__ = Albavision
diff --git a/src/streamlink/plugins/idf1.py b/src/streamlink/plugins/idf1.py
index 0a083391..b3220b4a 100644
--- a/src/streamlink/plugins/idf1.py
+++ b/src/streamlink/plugins/idf1.py
@@ -11,7 +11,7 @@ class IDF1(Plugin):
     DACAST_API_URL = 'https://json.dacast.com/b/{}/{}/{}'
     DACAST_TOKEN_URL = 'https://services.dacast.com/token/i/b/{}/{}/{}'
 
-    _url_re = re.compile(r'http://www\.idf1\.fr/(videos/[^/]+/[^/]+\.html|live\b)')
+    _url_re = re.compile(r'https?://www\.idf1\.fr/(videos/[^/]+/[^/]+\.html|live\b)')
     _video_id_re = re.compile(r"dacast\('(?P<broadcaster_id>\d+)_(?P<video_type>[a-z]+)_(?P<video_id>\d+)', 'replay_content', data\);")
     _video_id_alt_re = re.compile(r'<script src="https://player.dacast.com/js/player.js" id="(?P<broadcaster_id>\d+)_(?P<video_type>[cf])_(?P<video_id>\d+)"')
     _player_url = 'http://ssl.p.jwpcdn.com/player/v/7.12.6/jwplayer.flash.swf'
diff --git a/src/streamlink/plugins/playtv.py b/src/streamlink/plugins/playtv.py
index 4d6056a5..968949ac 100644
--- a/src/streamlink/plugins/playtv.py
+++ b/src/streamlink/plugins/playtv.py
@@ -1,3 +1,5 @@
+import base64
+import json
 import re
 
 from streamlink.plugin import Plugin
@@ -5,11 +7,17 @@ from streamlink.plugin.api import validate
 from streamlink.stream import HDSStream, HLSStream
 
 
+def jwt_decode(token):
+     info, payload, sig = token.split(".")
+     data = base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4))
+     return json.loads(data)
+
+
 class PlayTV(Plugin):
-    FORMATS_URL = 'http://playtv.fr/player/initialize/{0}/'
-    API_URL = 'http://playtv.fr/player/play/{0}/?format={1}&language={2}&bitrate={3}'
+    FORMATS_URL = 'https://playtv.fr/player/initialize/{0}/'
+    API_URL = 'https://playtv.fr/player/play/{0}/?format={1}&language={2}&bitrate={3}'
 
-    _url_re = re.compile(r'http://(?:playtv\.fr/television|play\.tv/live-tv/\d+)/(?P<channel>[^/]+)/?')
+    _url_re = re.compile(r'https?://(?:playtv\.fr/television|(:?\w+\.)?play\.tv/live-tv/\d+)/(?P<channel>[^/]+)/?')
 
     _formats_schema = validate.Schema({
         'streams': validate.any(
@@ -27,9 +35,13 @@ class PlayTV(Plugin):
             }
         )
     })
-    _api_schema = validate.Schema({
-        'url': validate.url()
-    })
+
+    _api_schema = validate.Schema(
+        validate.transform(lambda x: jwt_decode(x)),
+        {
+            'url': validate.url()
+        }
+    )
 
     @classmethod
     def can_handle_url(cls, url):
@@ -57,7 +69,7 @@ class PlayTV(Plugin):
                         continue
                     api_url = self.API_URL.format(channel, protocol, language, bitrate['value'])
                     res = self.session.http.get(api_url)
-                    video_url = self.session.http.json(res, schema=self._api_schema)['url']
+                    video_url = self._api_schema.validate(res.text)['url']
                     bs = '{0}k'.format(bitrate['value'])
 
                     if protocol == 'hls':
diff --git a/src/streamlink/plugins/twitcasting.py b/src/streamlink/plugins/twitcasting.py
new file mode 100644
index 00000000..7d3bb479
--- /dev/null
+++ b/src/streamlink/plugins/twitcasting.py
@@ -0,0 +1,202 @@
+import logging
+import re
+import websocket
+
+from streamlink import logger
+from streamlink.buffers import RingBuffer
+from streamlink.compat import urlparse, unquote_plus
+from streamlink.plugin import Plugin, PluginError
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.plugin.api.utils import parse_json, parse_query
+from streamlink.plugin.plugin import parse_url_params
+from streamlink.stream import Stream
+from streamlink.stream.stream import StreamIO
+from streamlink.utils import update_scheme
+from threading import Thread, Event
+
+log = logging.getLogger(__name__)
+
+class TwitCasting(Plugin):
+
+    _url_re = re.compile(r"http(s)?://twitcasting.tv/(?P<channel>[^/]+)", re.VERBOSE)
+    _STREAM_INFO_URL = "https://twitcasting.tv/streamserver.php?target={channel}&mode=client"
+    _STREAM_REAL_URL = "{proto}://{host}/ws.app/stream/{movie_id}/fmp4/bd/1/1500?mode={mode}"
+
+    _STREAM_INFO_SCHEMA = validate.Schema({
+        "movie": {
+            "id": int,
+            "live": bool
+        },
+        "fmp4": {
+            "host": validate.text,
+            "proto": validate.text,
+            "source": bool,
+            "mobilesource": bool
+        }
+    })
+
+    def __init__(self, url):
+        Plugin.__init__(self, url)
+        match = self._url_re.match(url).groupdict()
+        self.channel = match.get("channel")
+        self.session.http.headers.update({'User-Agent': useragents.CHROME})
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def _get_streams(self):
+        stream_info = self._get_stream_info()
+        log.debug("Live stream info: {}".format(stream_info))
+
+        if not stream_info["movie"]["live"]:
+            raise PluginError("The live stream is offline")
+
+        # Keys are already validated by schema above
+        proto = stream_info["fmp4"]["proto"]
+        host = stream_info["fmp4"]["host"]
+        movie_id = stream_info["movie"]["id"]
+
+        if stream_info["fmp4"]["source"]:
+            mode = "main" # High quality
+        elif stream_info["fmp4"]["mobilesource"]:
+            mode = "mobilesource" # Medium quality
+        else:
+            mode = "base" # Low quality
+
+        if (proto == '') or (host == '') or (not movie_id):
+            raise PluginError("No stream available for user {}".format(self.channel))
+
+        real_stream_url = self._STREAM_REAL_URL.format(proto=proto, host=host, movie_id=movie_id, mode=mode)
+        log.debug("Real stream url: {}".format(real_stream_url))
+
+        return {mode:TwitCastingStream(session=self.session, url=real_stream_url)}
+
+    def _get_stream_info(self):
+        url = self._STREAM_INFO_URL.format(channel=self.channel)
+        res = self.session.http.get(url)
+        return self.session.http.json(res, schema=self._STREAM_INFO_SCHEMA)
+
+
+class TwitCastingWsClient(Thread):
+    """
+    Recieve stream data from TwitCasting server via WebSocket.
+    """
+    def __init__(self, url, buffer, proxy=""):
+        Thread.__init__(self)
+        self.stopped = Event()
+        self.url = url
+        self.buffer = buffer
+        self.proxy = proxy
+        self.ws = None
+
+    @staticmethod
+    def parse_proxy_url(purl):
+        """
+        Credit: streamlink/plugins/ustreamtv.py:UHSClient:parse_proxy_url()
+        """
+        proxy_options = {}
+        if purl:
+            p = urlparse(purl)
+            proxy_options['proxy_type'] = p.scheme
+            proxy_options['http_proxy_host'] = p.hostname
+            if p.port:
+                proxy_options['http_proxy_port'] = p.port
+            if p.username:
+                proxy_options['http_proxy_auth'] = (unquote_plus(p.username), unquote_plus(p.password or ""))
+        return proxy_options
+
+    def stop(self):
+        if not self.stopped.wait(0):
+            log.debug("Stopping WebSocket client...")
+            self.stopped.set()
+            self.ws.close()
+
+    def run(self):
+        if self.stopped.wait(0):
+            return
+
+        def on_message(ws, data):
+            if not self.stopped.wait(0):
+                try:
+                    self.buffer.write(data)
+                except Exception as err:
+                    log.error(err)
+                    self.stop()
+
+        def on_error(ws, error):
+            log.error(error)
+
+        def on_close(ws):
+            log.debug("Disconnected from WebSocket server")
+
+        # Parse proxy string for websocket-client
+        proxy_options = self.parse_proxy_url(self.proxy)
+        if proxy_options.get('http_proxy_host'):
+            log.debug("Connecting to {0} via proxy ({1}://{2}:{3})".format(self.url,
+                                                                        proxy_options.get('proxy_type') or "http",
+                                                                        proxy_options.get('http_proxy_host'),
+                                                                        proxy_options.get('http_proxy_port') or 80))
+        else:
+            log.debug("Connecting to {0} without proxy".format(self.url))
+
+        # Connect to WebSocket server
+        self.ws = websocket.WebSocketApp(self.url,
+                                        header=["User-Agent: {0}".format(useragents.CHROME)],
+                                        on_message=on_message,
+                                        on_error=on_error,
+                                        on_close=on_close)
+        self.ws.run_forever(origin="https://twitcasting.tv/", **proxy_options)
+
+
+class TwitCastingReader(StreamIO):
+
+    def __init__(self, stream, timeout=None, **kwargs):
+        StreamIO.__init__(self)
+        self.stream = stream
+        self.session = stream.session
+        self.timeout = timeout if timeout else self.session.options.get("stream-timeout")
+        self.buffer = None
+
+        if logger.root.level <= logger.DEBUG:
+            websocket.enableTrace(True, log)
+
+    def open(self):
+        # Prepare buffer
+        buffer_size = self.session.get_option("ringbuffer-size")
+        self.buffer = RingBuffer(buffer_size)
+
+        log.debug("Starting WebSocket client")
+        self.client = TwitCastingWsClient(self.stream.url, buffer=self.buffer,
+                                            proxy=self.session.get_option("http-proxy"))
+        self.client.setDaemon(True)
+        self.client.start()
+
+    def close(self):
+        self.client.stop()
+        self.buffer.close()
+
+    def read(self, size):
+        if not self.buffer:
+            return b""
+
+        return self.buffer.read(size, block=(not self.client.stopped.wait(0)),
+                                timeout=self.timeout)
+
+
+class TwitCastingStream(Stream):
+    def __init__(self, session, url):
+        super(TwitCastingStream, self).__init__(session)
+        self.url = url
+
+    def __repr__(self):
+        return "<TwitCastingStream({0!r})>".format(self.url)
+
+    def open(self):
+        reader = TwitCastingReader(self)
+        reader.open()
+        return reader
+
+
+__plugin__ = TwitCasting


Test patch:
diff --git a/tests/plugins/test_albavision.py b/tests/plugins/test_albavision.py
new file mode 100644
index 00000000..0fd72001
--- /dev/null
+++ b/tests/plugins/test_albavision.py
@@ -0,0 +1,29 @@
+import unittest
+
+import pytest
+
+from streamlink.plugins.albavision import Albavision
+
+
+class TestPluginAlbavision:
+    valid_urls = [
+        ("https://www.elnueve.com.ar/en-vivo",),
+        ("http://www.rts.com.ec/envivo",),
+        ("https://www.tvc.com.ec/envivo",),
+    ]
+    invalid_urls = [
+        ("https://news.now.com/home/local",),
+        ("http://media.now.com.hk/",),
+        ("https://www.youtube.com",)
+    ]
+
+    @pytest.mark.parametrize(["url"], valid_urls)
+    def test_can_handle_url(self, url):
+        assert Albavision.can_handle_url(url), "url should be handled"
+
+    @pytest.mark.parametrize(["url"], invalid_urls)
+    def test_can_handle_url_negative(self, url):
+        assert not Albavision.can_handle_url(url), "url should not be handled"
+
+    def test_transform(self):
+        assert Albavision.transform_token(u'6b425761cc8a86569b1a05a9bf1870c95fca717dOK', 436171) == "6b425761cc8a86569b1a05a9bf1870c95fca717d"
diff --git a/tests/plugins/test_idf1.py b/tests/plugins/test_idf1.py
index 5b753494..737995a1 100644
--- a/tests/plugins/test_idf1.py
+++ b/tests/plugins/test_idf1.py
@@ -6,13 +6,13 @@ from streamlink.plugins.idf1 import IDF1
 class TestPluginIDF1(unittest.TestCase):
     def test_can_handle_url(self):
         # should match
-        self.assertTrue(IDF1.can_handle_url("http://www.idf1.fr/live"))
-        self.assertTrue(IDF1.can_handle_url("http://www.idf1.fr/videos/jlpp/best-of-2018-02-24-partie-2.html"))
+        self.assertTrue(IDF1.can_handle_url("https://www.idf1.fr/live"))
+        self.assertTrue(IDF1.can_handle_url("https://www.idf1.fr/videos/jlpp/best-of-2018-02-24-partie-2.html"))
         self.assertTrue(IDF1.can_handle_url("http://www.idf1.fr/videos/buzz-de-noel/partie-2.html"))
 
         # shouldn't match
-        self.assertFalse(IDF1.can_handle_url("http://www.idf1.fr/"))
-        self.assertFalse(IDF1.can_handle_url("http://www.idf1.fr/videos"))
-        self.assertFalse(IDF1.can_handle_url("http://www.idf1.fr/programmes/emissions/idf1-chez-vous.html"))
+        self.assertFalse(IDF1.can_handle_url("https://www.idf1.fr/"))
+        self.assertFalse(IDF1.can_handle_url("https://www.idf1.fr/videos"))
+        self.assertFalse(IDF1.can_handle_url("https://www.idf1.fr/programmes/emissions/idf1-chez-vous.html"))
         self.assertFalse(IDF1.can_handle_url("http://www.tvcatchup.com/"))
         self.assertFalse(IDF1.can_handle_url("http://www.youtube.com/"))
diff --git a/tests/plugins/test_twitcasting.py b/tests/plugins/test_twitcasting.py
new file mode 100644
index 00000000..fac0da43
--- /dev/null
+++ b/tests/plugins/test_twitcasting.py
@@ -0,0 +1,20 @@
+import unittest
+
+from streamlink.plugins.twitcasting import TwitCasting
+
+
+class TestPluginTwitCasting(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'https://twitcasting.tv/c:kk1992kkkk',
+            'https://twitcasting.tv/icchy8591/movie/566593738',
+        ]
+        for url in should_match:
+            self.assertTrue(TwitCasting.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'https://example.com/index.html',
+        ]
+        for url in should_not_match:
+            self.assertFalse(TwitCasting.can_handle_url(url))