Repository: streamlink/streamlink

Issue:
plugins.svtplay: No streams found on svt1+svt2
### 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

svt1 and svt2 don't work.  Kunskapskanalen still works.

```
[plugins.svtplay][debug] Path=/kanaler/svt1
[plugins.svtplay][debug] Live ID=ch-svt1
error: No playable streams found on this URL: https://www.svtplay.se/kanaler/svt1
```

One month ago it worked so something have changed since then.

### Debug log

```text
streamlink -l debug https://www.svtplay.se/kanaler/svt1
[cli][debug] OS:         Linux-5.15.56-v7l+-armv7l-with-glibc2.31
[cli][debug] Python:     3.9.2
[cli][debug] Streamlink: 5.0.1
[cli][debug] Dependencies:
[cli][debug]  PySocks: 1.7.1
[cli][debug]  isodate: 0.6.0
[cli][debug]  lxml: 4.6.3
[cli][debug]  pycountry: 20.7.3
[cli][debug]  pycryptodomex: 3.9.7
[cli][debug]  requests: 2.25.1
[cli][debug]  websocket-client: 0.57.0
[cli][debug] Arguments:
[cli][debug]  url=https://www.svtplay.se/kanaler/svt1
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin svtplay for URL https://www.svtplay.se/kanaler/svt1
[plugins.svtplay][debug] Path=/kanaler/svt1
[plugins.svtplay][debug] Live ID=ch-svt1
error: No playable streams found on this URL: https://www.svtplay.se/kanaler/svt1

Kunskapskanalen still works:
streamlink -l debug https://www.svtplay.se/kanaler/kunskapskanalen
[cli][debug] OS:         Linux-5.15.56-v7l+-armv7l-with-glibc2.31
[cli][debug] Python:     3.9.2
[cli][debug] Streamlink: 5.0.1
[cli][debug] Dependencies:
[cli][debug]  PySocks: 1.7.1
[cli][debug]  isodate: 0.6.0
[cli][debug]  lxml: 4.6.3
[cli][debug]  pycountry: 20.7.3
[cli][debug]  pycryptodomex: 3.9.7
[cli][debug]  requests: 2.25.1
[cli][debug]  websocket-client: 0.57.0
[cli][debug] Arguments:
[cli][debug]  url=https://www.svtplay.se/kanaler/kunskapskanalen
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin svtplay for URL https://www.svtplay.se/kanaler/kunskapskanalen
[plugins.svtplay][debug] Path=/kanaler/kunskapskanalen
[plugins.svtplay][debug] Live ID=ch-kunskapskanalen
[utils.l10n][debug] Language code: en_GB
[stream.dash][debug] Available languages for DASH audio streams: sv-x-tal, sv (using: sv)
Available streams: 234p (worst), 360p, 540p, 720p_alt, 720p (best)
```



Discussion:
The plugin currently expects DASH streams for live content. The site's API however only returns HLS streams, with optional subtitles. This means that the plugin needs an update, and ideally a complete rewrite, because it's very much outdated.

The content however is geo-restricted, which is why nobody has updated the plugin yet. I'm surprised I was able to access the API and see the JSON response. I can't access the HLS playlists though, so even if someone with a VPN takes a look at rewriting the plugin, a confirmation will be needed before merging.

Btw, as a side note, you are not using the latest stable version, and the dependency versions of your currently used Streamlink version do not match Streamlink's requirements. For the SVT plugin though, this is not a problem. For others however and for other code part of Streamlink, it is. Not sure if you're using a system package of your distro's package manager, but if that's the case, then the packager should fix this.
I seem to remember that when I did a rewrite/fix on this plugin (>2 years ago now), there were DASH and HLS streams.  I used DASH at the time because there seemed to be (then) an issue with ffmpeg and muxing on HLS streams.  Hopefully that's not the case now (or can be fixed if it is).

Resolving patch:
diff --git a/src/streamlink/plugins/svtplay.py b/src/streamlink/plugins/svtplay.py
--- a/src/streamlink/plugins/svtplay.py
+++ b/src/streamlink/plugins/svtplay.py
@@ -13,114 +13,145 @@
 from streamlink.plugin.api import validate
 from streamlink.stream.dash import DASHStream
 from streamlink.stream.ffmpegmux import MuxedStream
+from streamlink.stream.hls import HLSStream
 from streamlink.stream.http import HTTPStream
 
 log = logging.getLogger(__name__)
 
 
 @pluginmatcher(re.compile(
-    r'https?://(?:www\.)?svtplay\.se(/(kanaler/)?.*)'
+    r"https?://(?:www\.)?svtplay\.se/(?P<live>kanaler/)?"
 ))
 @pluginargument(
     "mux-subtitles",
     is_global=True,
 )
 class SVTPlay(Plugin):
-    api_url = 'https://api.svt.se/videoplayer-api/video/{0}'
-
-    latest_episode_url_re = re.compile(r'''
-        data-rt="top-area-play-button"\s+href="(?P<url>[^"]+)"
-    ''', re.VERBOSE)
-
-    live_id_re = re.compile(r'.*/(?P<live_id>[^?]+)')
-
-    _video_schema = validate.Schema({
-        validate.optional('programTitle'): validate.text,
-        validate.optional('episodeTitle'): validate.text,
-        'videoReferences': [{
-            'url': validate.url(),
-            'format': validate.text,
-        }],
-        validate.optional('subtitleReferences'): [{
-            'url': validate.url(),
-            'format': validate.text,
-        }],
-    })
-
-    def _set_metadata(self, data, category):
-        if 'programTitle' in data:
-            self.author = data['programTitle']
-
-        self.category = category
-
-        if 'episodeTitle' in data:
-            self.title = data['episodeTitle']
-
-    def _get_live(self, path):
-        match = self.live_id_re.search(path)
-        if match is None:
+    _URL_API_VIDEO = "https://api.svt.se/videoplayer-api/video/{item}"
+    _MAP_CHANNEL_NAMES = {
+        "svt1": "ch-svt1",
+        "svt2": "ch-svt2",
+        "svtbarn": "ch-barnkanalen",
+        "kunskapskanalen": "ch-kunskapskanalen",
+        "svt24": "ch-svt24",
+    }
+
+    def _api_call(self, item):
+        _schema_items = validate.all(
+            [
+                validate.all(
+                    {
+                        "format": str,
+                        "url": validate.url(),
+                    },
+                    validate.union_get("format", "url"),
+                ),
+            ],
+            validate.transform(dict),
+        )
+
+        self.author, self.title, videos, subtitles = self.session.http.get(
+            self._URL_API_VIDEO.format(item=item),
+            schema=validate.Schema(
+                validate.parse_json(),
+                {
+                    validate.optional("programTitle"): str,
+                    validate.optional("episodeTitle"): str,
+                    "videoReferences": _schema_items,
+                    validate.optional("subtitleReferences"): _schema_items,
+                },
+                validate.union_get(
+                    "programTitle",
+                    "episodeTitle",
+                    "videoReferences",
+                    "subtitleReferences",
+                ),
+            ),
+        )
+
+        return videos, subtitles
+
+    def _get_live(self):
+        live_id = "/".join(urlparse(self.url).path.split("/")[2:])
+        if not live_id:
             return
 
-        live_id = "ch-{0}".format(match.group('live_id'))
-        log.debug("Live ID={0}".format(live_id))
+        live_id = self._MAP_CHANNEL_NAMES.get(live_id, f"ch-{live_id}")
+        log.debug(f"Live ID={live_id}")
+        self.category = "Live"
+        videos, subtitles = self._api_call(live_id)
 
-        res = self.session.http.get(self.api_url.format(live_id))
-        api_data = self.session.http.json(res, schema=self._video_schema)
-
-        self._set_metadata(api_data, 'Live')
-
-        for playlist in api_data['videoReferences']:
-            if playlist['format'] == 'dashhbbtv':
-                yield from DASHStream.parse_manifest(self.session, playlist['url']).items()
+        return self._select_streams(videos, subtitles)
 
     def _get_vod(self):
-        vod_id = self._get_vod_id(self.url)
+        def get_vod_id(url):
+            return dict(parse_qsl(urlparse(url).query)).get("id")
+
+        vod_id = get_vod_id(self.url)
 
         if vod_id is None:
-            res = self.session.http.get(self.url)
-            match = self.latest_episode_url_re.search(res.text)
-            if match is None:
-                return
-            vod_id = self._get_vod_id(match.group("url"))
+            vod_url = self.session.http.get(self.url, schema=validate.Schema(
+                validate.parse_html(),
+                validate.xml_xpath_string(".//*[@data-rt='top-area-play-button'][@href][1]/@href"),
+            ))
+            if vod_url:
+                vod_id = get_vod_id(vod_url)
 
         if vod_id is None:
             return
 
-        log.debug("VOD ID={0}".format(vod_id))
+        log.debug(f"VOD ID={vod_id}")
+        self.category = "Live"
+        videos, subtitles = self._api_call(vod_id)
 
-        res = self.session.http.get(self.api_url.format(vod_id))
-        api_data = self.session.http.json(res, schema=self._video_schema)
+        return self._select_streams(videos, subtitles)
 
-        self._set_metadata(api_data, 'VOD')
+    def _select_streams(self, videos, subtitles):
+        # the goal is to have streams with the widest range of qualities/substreams and highest bitrate at the top
+        stream_priorities = {
+            "dashhbbtv": DASHStream,  # DASH AVC
+            "dash-hbbtv-avc": DASHStream,  # DASH AVC
+            "dash-avc": DASHStream,  # DASH AVC
+            "dash-full": DASHStream,  # DASH AVC
+            "dash": DASHStream,  # DASH AVC
 
-        substreams = {}
-        if 'subtitleReferences' in api_data:
-            for subtitle in api_data['subtitleReferences']:
-                if subtitle['format'] == 'webvtt':
-                    log.debug("Subtitle={0}".format(subtitle['url']))
-                    substreams[subtitle['format']] = HTTPStream(
-                        self.session,
-                        subtitle['url'],
-                    )
+            "hlswebvtt": HLSStream,  # HLS with subtitles
+            "hls-cmaf-live-vtt": HLSStream,  # HLS with subtitles
+            "hls-ts-avc": HLSStream,  # HLS with MPEG-TS
+            "hls-ts-full": HLSStream,  # HLS with MPEG-TS
+            "hls": HLSStream,  # HLS with MPEG-TS
+            "hls-cmaf-live": HLSStream,  # HLS with fMP4
+            "hls-cmaf-full": HLSStream,  # HLS with fMP4
 
-        for manifest in api_data['videoReferences']:
-            if manifest['format'] == 'dashhbbtv':
-                for q, s in DASHStream.parse_manifest(self.session, manifest['url']).items():
-                    if self.get_option('mux_subtitles') and substreams:
-                        yield q, MuxedStream(self.session, s, subtitles=substreams)
-                    else:
-                        yield q, s
+            "dash-hbbtv-hevc": DASHStream,  # DASH HEVC (low prio, because of potential user decoder issues)
 
-    def _get_vod_id(self, url):
-        qs = dict(parse_qsl(urlparse(url).query))
-        return qs.get("id")
+            "hls-ts-lb-full": HLSStream,  # low bitrate
+            "hls-cmaf-lb-full": HLSStream,  # low bitrate
+            "dash-lb-full": DASHStream,  # low bitrate
+        }
 
-    def _get_streams(self):
-        path, live = self.match.groups()
-        log.debug("Path={0}".format(path))
+        for fmt, streamtype in stream_priorities.items():
+            if fmt not in videos:
+                continue
+
+            if streamtype is HLSStream:
+                return HLSStream.parse_variant_playlist(self.session, videos[fmt], name_fmt="{pixels}_{bitrate}")
 
-        if live:
-            return self._get_live(path)
+            if streamtype is DASHStream:
+                mux_subtitles = self.get_option("mux_subtitles")
+                subtitlestreams = {}
+                if mux_subtitles and "webvtt" in subtitles:
+                    subtitlestreams["webvtt"] = HTTPStream(self.session, subtitles["webvtt"])
+
+                dash_streams = DASHStream.parse_manifest(self.session, videos[fmt])
+                if not subtitlestreams:
+                    return dash_streams
+
+                return {q: MuxedStream(self.session, s, subtitles=subtitlestreams) for q, s in dash_streams.items()}
+
+    def _get_streams(self):
+        if self.match["live"]:
+            return self._get_live()
         else:
             return self._get_vod()
 


Test patch: