Repository: streamlink/streamlink

Issue:
Plugin Albavision: Peruvian channels ATV and ATVMas not working
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest stable release

### Description

Could someone please have a look at the plugin "albavision"? 
The channels ATV and ATVMás don't start. The URLs have slightly changed, but according to the error message in the log, there must be a bigger problem (fetching the token fails). 

### Debug log

```text
C:\Users\malvinas2>streamlink http://www.atv.pe/envivo/ATVMas
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.9.8
[cli][debug] Streamlink: 3.0.3
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(1.2.1)
[cli][debug] Arguments:
[cli][debug]  url=http://www.atv.pe/envivo/ATVMas
[cli][debug]  --loglevel=debug
[cli][debug]  --player="D:\VideoLAN\VLC\vlc.exe" --file-caching=5000
[cli][debug]  --ffmpeg-ffmpeg=C:\Program Files (x86)\Streamlink\ffmpeg\ffmpeg.exe
[cli][info] Found matching plugin albavision for URL http://www.atv.pe/envivo/ATVMas
Traceback (most recent call last):
  File "runpy.py", line 197, in _run_module_as_main
  File "runpy.py", line 87, in _run_code
  File "C:\Program Files (x86)\Streamlink\bin\streamlink.exe\__main__.py", line 18, in <module>
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink_cli\main.py", line 1056, in main
    handle_url()
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink_cli\main.py", line 565, in handle_url
    streams = fetch_streams(plugin)
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink_cli\main.py", line 459, in fetch_streams
    return plugin.streams(stream_types=args.stream_types,
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\plugin\plugin.py", line 336, in streams
    ostreams = self._get_streams()
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\plugins\albavision.py", line 104, in _get_streams
    token = self._get_token(channelnumber)
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\plugins\albavision.py", line 80, in _get_token
    token_url = self._get_token_url(channelnumber)
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\plugins\albavision.py", line 49, in _get_token_url
    token = self._get_live_url_token(channelnumber)
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\plugins\albavision.py", line 74, in _get_live_url_token
    log.debug("Token input: {0}".format(m[channelnumber]))
IndexError: list index out of range
```



Discussion:
direct urls can be used instead of the plugin

https://d2tr4gdfol9ja.cloudfront.net/atv/smil:atv.smil/playlist.m3u8
https://d2tr4gdfol9ja.cloudfront.net/atv/smil:atv-mas.smil/playlist.m3u8
I was going to look at this at some point.  Do you think you'll be removing this plugin?
> Do you think you'll be removing this plugin?

probably not, but it needs an update/cleanup.

Only ATV can be used without a token,
the other sites still use them like https://github.com/streamlink/streamlink/issues/4224

`https://do-antena7-antena7-live.ned.media/antena7/smil:antena7.smil/playlist.m3u8?iut=XXX`
I don't know if this is just a bad habit of Latin American broadcasters or a general problem. In any case, they often change the type of broadcast: E.g. they start as a direct stream from their website, a short time later they switch to a CDN and another few months later they offer the stream on YouTube. And so on, and so on...
Perhaps it is better to put plugins from such providers into a kind of quarantine instead of removing them completely?
'El Nueve', an argentinian TV-channel included in the Albavision-plugin, can also be used without a token: 
[https://d2r6nlw8e5ph9i.cloudfront.net/elnueve/smil:elnueve.smil/playlist.m3u8](https://d2r6nlw8e5ph9i.cloudfront.net/elnueve/smil:elnueve.smil/playlist.m3u8)
Interestingly, two of the resolutions on "El Nueve" are broken: 243p and 360p.  Neither of those seem to work in the web player either.  It looks like an audio stream issue to me.  In mpv you just get a static picture (presumably because it's waiting to sync the audio), vlc ploughs on and plays the video without any audio.  Both 486p and 720p are working though.

Resolving patch:
diff --git a/src/streamlink/plugins/albavision.py b/src/streamlink/plugins/albavision.py
--- a/src/streamlink/plugins/albavision.py
+++ b/src/streamlink/plugins/albavision.py
@@ -1,59 +1,154 @@
-"""
-Support for the live streams on Albavision sites
-    - http://www.tvc.com.ec/envivo
-    - http://www.rts.com.ec/envivo
-    - http://www.elnueve.com.ar/en-vivo
-    - http://www.atv.pe/envivo/ATV
-    - http://www.atv.pe/envivo/ATVMas
-"""
 import logging
 import re
 import time
-from urllib.parse import quote, urlencode, urlparse
 
-from streamlink.plugin import Plugin, PluginError, pluginmatcher
+from streamlink.plugin import Plugin, pluginmatcher
+from streamlink.plugin.api import validate
 from streamlink.stream.hls import HLSStream
-from streamlink.utils.url import update_scheme
+from streamlink.utils.url import update_qsd
 
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(
-    r"https?://(?:www\.)?(tvc\.com\.ec|rts\.com\.ec|elnueve\.com\.ar|atv\.pe)/en-?vivo(?:/ATV(?:Mas)?)?"
-))
+@pluginmatcher(re.compile(r"""
+    https?://(?:www\.)?
+    (
+        antena7\.com\.do
+        |
+        atv\.pe
+        |
+        c9n\.com\.py
+        |
+        canal10\.com\.ni
+        |
+        canal12\.com\.sv
+        |
+        chapintv\.com
+        |
+        elnueve\.com\.ar
+        |
+        redbolivision\.tv\.bo
+        |
+        repretel\.com
+        |
+        rts\.com\.ec
+        |
+        snt\.com\.py
+        |
+        tvc\.com\.ec
+        |
+        vtv\.com\.hn
+    )
+    /
+    (?:
+        (?:
+            en-?vivo(?:-atv(?:mas)?|-canal-?\d{1,2})?
+        )
+        |
+        upptv
+    )
+    (?:/|\#)?$
+""", re.VERBOSE))
 class Albavision(Plugin):
-    _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 = {
-        'ATV': 'http://dgrzfw9otv9ra.cloudfront.net/player_atv.html?iut=',
-        'ATVMas': 'http://dgrzfw9otv9ra.cloudfront.net/player_atv_mas.html?iut=',
-        'Canal5': 'http://dxejh4fchgs18.cloudfront.net/player_televicentro.html?iut=',
-        'Guayaquil': 'http://d2a6tcnofawcbm.cloudfront.net/player_rts.html?iut=',
-        'Quito': 'http://d3aacg6baj4jn0.cloudfront.net/reproductor_rts_o_quito.html?iut=',
-    }
-
     def __init__(self, url):
         super().__init__(url)
         self._page = None
 
     @property
     def page(self):
-        if not self._page:
-            self._page = self.session.http.get(self.url)
+        if self._page is None:
+            self._page = self.session.http.get(self.url, schema=validate.Schema(
+                validate.parse_html(),
+            ))
         return self._page
 
-    def _get_token_url(self, channelnumber):
-        token = self._get_live_url_token(channelnumber)
-        if token:
-            m = self._token_url_re.findall(self.page.text)
-            token_url = m and m[channelnumber]
-            if token_url:
-                return token_url + token
-        else:
-            log.error("Could not find site token")
+    def _is_token_based_site(self):
+        schema = validate.Schema(
+            validate.xml_xpath_string(".//script[contains(text(), 'jQuery.get')]/text()"),
+        )
+        is_token_based_site = validate.validate(schema, self.page) is not None
+        log.debug(f"is_token_based_site={is_token_based_site}")
+        return is_token_based_site
+
+    def _get_live_url(self):
+        live_url_re = re.compile(r"""LIVE_URL\s*=\s*['"]([^'"]+)['"]""")
+        schema = validate.Schema(
+            validate.xml_xpath_string(".//script[contains(text(), 'LIVE_URL')]/text()"),
+            validate.any(None, validate.all(
+                validate.transform(live_url_re.search),
+                validate.any(None, validate.all(
+                    validate.get(1),
+                    validate.url(),
+                )),
+            )),
+        )
+        live_url = validate.validate(schema, self.page)
+        log.debug(f"live_url={live_url}")
+        return live_url
+
+    def _get_token_req_url(self):
+        token_req_host_re = re.compile(r"""jQuery\.get\s*\(['"]([^'"]+)['"]""")
+        schema = validate.Schema(
+            validate.xml_xpath_string(".//script[contains(text(), 'LIVE_URL')]/text()"),
+            validate.any(None, validate.all(
+                validate.transform(token_req_host_re.search),
+                validate.any(None, validate.all(
+                    validate.get(1),
+                    validate.url(),
+                )),
+            )),
+        )
+        token_req_host = validate.validate(schema, self.page)
+        log.debug(f"token_req_host={token_req_host}")
+
+        token_req_str_re = re.compile(r"""Math\.floor\(Date\.now\(\)\s*/\s*3600000\),\s*['"]([^'"]+)['"]""")
+        schema = validate.Schema(
+            validate.xml_xpath_string(".//script[contains(text(), 'LIVE_URL')]/text()"),
+            validate.any(None, validate.all(
+                validate.transform(token_req_str_re.search),
+                validate.any(None, validate.all(
+                    validate.get(1),
+                    str,
+                )),
+            )),
+        )
+        token_req_str = validate.validate(schema, self.page)
+        log.debug(f"token_req_str={token_req_str}")
+        if not token_req_str:
+            return
+
+        date = int(time.time() // 3600)
+        token_req_token = self.transform_token(token_req_str, date) or self.transform_token(token_req_str, date - 1)
+
+        if token_req_host and token_req_token:
+            return update_qsd(token_req_host, {"rsk": token_req_token})
+
+    def _get_token(self):
+        token_req_url = self._get_token_req_url()
+        if not token_req_url:
+            return
+
+        res = self.session.http.get(token_req_url, schema=validate.Schema(
+            validate.parse_json(), {
+                "success": bool,
+                validate.optional("error"): int,
+                validate.optional("token"): str,
+            },
+        ))
+
+        if not res["success"]:
+            if res["error"]:
+                log.error(f"Token request failed with error: {res['error']}")
+            else:
+                log.error("Token request failed")
+            return
+
+        if not res["token"]:
+            log.error("Token not found in response")
+            return
+        token = res["token"]
+        log.debug(f"token={token}")
+        return token
 
     @staticmethod
     def transform_token(token_in, date):
@@ -67,59 +162,21 @@ def transform_token(token_in, date):
         if token_out.endswith("OK"):
             return token_out[:-2]
         else:
-            log.error("Invalid site token: {0} => {1}".format(token_in, token_out))
-
-    def _get_live_url_token(self, channelnumber):
-        m = self._token_input_re.findall(self.page.text)
-        log.debug("Token input: {0}".format(m[channelnumber]))
-        if m:
-            date = int(time.time() // 3600)
-            return self.transform_token(m[channelnumber], date) or self.transform_token(m[channelnumber], date - 1)
-
-    def _get_token(self, channelnumber):
-        token_url = self._get_token_url(channelnumber)
-        if token_url:
-            res = self.session.http.get(token_url)
-            data = self.session.http.json(res)
-            if data['success']:
-                return data['token']
+            log.error(f"Invalid site token: {token_in} => {token_out}")
 
     def _get_streams(self):
-        m = self._live_url_re.search(self.page.text)
-        playlist_url = m and update_scheme("https://", m.group(1), force=False)
-        player_url = self.url
-        live_channel = None
-        p = urlparse(player_url)
-        channelnumber = 0
-        if p.netloc.endswith("tvc.com.ec"):
-            live_channel = "Canal5"
-        elif p.netloc.endswith("rts.com.ec"):
-            live_channel = "Guayaquil"
-        elif p.netloc.endswith("atv.pe"):
-            if p.path.endswith(("ATVMas", "ATVMas/")):
-                live_channel = "ATVMas"
-                channelnumber = 1
-            else:
-                live_channel = "ATV"
-        token = self._get_token(channelnumber)
-        log.debug("token {0}".format(token))
-        if playlist_url:
-            log.debug("Found playlist URL in the page")
-        else:
-            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("https://", m.group(1), force=False)
-            else:
-                log.error("Could not find the live channel")
+        live_url = self._get_live_url()
+        if not live_url:
+            log.info("This stream may be off-air or not available in your country")
+            return
 
-        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})
+        if self._is_token_based_site():
+            token = self._get_token()
+            if not token:
+                return
+            return HLSStream.parse_variant_playlist(self.session, update_qsd(live_url, {"iut": token}))
+        else:
+            return HLSStream.parse_variant_playlist(self.session, live_url)
 
 
 __plugin__ = Albavision


Test patch:
diff --git a/tests/plugins/test_albavision.py b/tests/plugins/test_albavision.py
--- a/tests/plugins/test_albavision.py
+++ b/tests/plugins/test_albavision.py
@@ -8,15 +8,50 @@ class TestPluginCanHandleUrlAlbavision(PluginCanHandleUrl):
     __plugin__ = Albavision
 
     should_match = [
-        "https://www.elnueve.com.ar/en-vivo",
-        "http://www.rts.com.ec/envivo",
-        "https://www.tvc.com.ec/envivo",
-        "http://www.atv.pe/envivo/ATV",
-        "http://www.atv.pe/envivo/ATVMas",
+        "http://antena7.com.do/envivo-canal-7/",
+        "http://www.antena7.com.do/envivo-canal-7/",
+        "https://antena7.com.do/envivo-canal-7/",
+        "https://www.antena7.com.do/envivo-canal7",
+        "https://www.antena7.com.do/envivo-canal7/",
+        "https://www.antena7.com.do/envivo-canal7#",
+        "https://www.antena7.com.do/envivo-canal-7#",
+        "https://www.antena7.com.do/en-vivo-canal-99/",
+        "https://www.antena7.com.do/en-vivo-canal-99#",
+        # All channel URLs from supported sites
+        "https://www.antena7.com.do/envivo-canal-7/",
+        "https://www.antena7.com.do/envivo-canal-21/",
+        "https://www.atv.pe/envivo-atv",
+        "https://www.atv.pe/envivo-atvmas",
+        "https://www.c9n.com.py/envivo/",
+        "https://www.canal10.com.ni/envivo/",
+        "https://www.canal12.com.sv/envivo/",
+        "https://www.chapintv.com/envivo-canal-3/",
+        "https://www.chapintv.com/envivo-canal-7/",
+        "https://www.chapintv.com/envivo-canal-23/",
+        "https://www.elnueve.com.ar/en-vivo/",
+        "https://www.redbolivision.tv.bo/envivo-canal-5/",
+        "https://www.redbolivision.tv.bo/upptv/",
+        "https://www.repretel.com/envivo-canal2/",
+        "https://www.repretel.com/envivo-canal4/",
+        "https://www.repretel.com/envivo-canal6/",
+        "https://www.repretel.com/en-vivo-canal-11/",
+        "https://www.rts.com.ec/envivo/",
+        "https://www.snt.com.py/envivo/",
+        "https://www.tvc.com.ec/envivo/",
+        "https://www.vtv.com.hn/envivo/",
     ]
     should_not_match = [
-        "https://news.now.com/home/local",
-        "http://media.now.com.hk/",
+        "https://fake.antena7.com.do/envivo-canal-7/",
+        "https://www.antena7.com.do/envivo-canal123",
+        "https://www.antena7.com.do/envivo-canal123/",
+        "https://www.antena7.com.do/envivo-canal-123",
+        "https://www.antena7.com.do/envivo-canal-123/",
+        "https://www.antena7.com.do/envivo-canal-123#",
+        "https://www.antena7.com.do/envivo-canalabc",
+        "https://www.antena7.com.do/envivo-canal-abc",
+        "https://www.antena7.com.do/envivo-canal-7/extra",
+        "https://www.antena7.com.do/envivo-canal-7#extra",
+        "https://www.antena7.com.do/something",
     ]