Repository: streamlink/streamlink

Issue:
plugins.pluto: Ad filtering not properly working for (some) streams
### Checklist

- [X] This is a [plugin issue](https://streamlink.github.io/plugins.html) and not [a different kind of issue](https://github.com/streamlink/streamlink/issues/new/choose)
- [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

streamlink 6.5.0

### Description

~$ streamlink --no-config --version
streamlink 6.5.0

For at least several weeks, Pluto has not been properly filtering out ads during the ad breaks. However, the issue manifests itself differently, depending on the Pluto channel, the time or show being presented on that channel, and the particular player (VLC and MPV) being used. Depending on the channel or time or show, the ads might be filtered like used to be normal. On other channels, most or all the ads are unfiltered. Sometimes, during an ad break, the show is paused, but the audio of the advertisements play, and the stream breaks. If the user starts watching a pluto channel during an ad break, I have seen the ads run smoothly and the actual program get filtered out as though it was the ad. 

The variable, changing nature of the issue makes it hard for me to select a good sample of debug log output for diagnosing the issue. A channel which is experiencing the problem today might start filtering out ads properly later in the day during a different show. Or maybe the issue exists during one day but manifests itself a little differently on the next day. I am pasting a sample debug log from a couple of days ago, from a stream which, at the time at least, was not filtering out ads but otherwise running fairly smoothly. Also keep in mind that issue #5475 is still relevant, which requires the user to know the old urls of each stream in order to use streamlink. Please let me know if this debug log is not sufficient for some reason.

Step 1: enter "streamlink https://pluto.tv/en/live-tv/star-trek-1 best --loglevel=debug --auto-version-check=True --player=mpv --player-no-close=True" (I use a config file to enter most of these options, and I use one-click launchers on my desktop Linux to enter the command into the terminal for each stream I am interested in).
Step 2: watch the stream and wait for an ad break
Step 3: observe results and hope that the stream does not break
Step 4: close out of the stream after the ad break is over and the normal content resumes
Step 5: copy and paste the debug log from the event into a text editor.

### Debug log

```text
[cli][debug] OS:         Linux-5.15.0-91-generic-x86_64-with-glibc2.35
[cli][debug] Python:     3.10.12
[cli][debug] OpenSSL:    OpenSSL 3.0.2 15 Mar 2022
[cli][debug] Streamlink: 6.5.0
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.7.22
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.8.0
[cli][debug]  pycountry: 20.7.3
[cli][debug]  pycryptodome: 3.19.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.22.2
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  typing-extensions: 4.8.0
[cli][debug]  urllib3: 2.1.0
[cli][debug]  websocket-client: 1.2.3
[cli][debug] Arguments:
[cli][debug]  url=https://pluto.tv/en/live-tv/star-trek-1
[cli][debug]  --loglevel=debug
[cli][debug]  --auto-version-check=True
[cli][debug]  --player=mpv
[cli][debug]  --player-no-close=True
[cli][debug]  --default-stream=['best']
[cli][info] Found matching plugin pluto for URL https://pluto.tv/en/live-tv/star-trek-1
[plugins.pluto][debug] slug=star-trek-1
[plugins.pluto][debug] app_version=7.8.3-0cae52359d82558db2247a6ec8c0aca12b24f675
[plugins.pluto][debug] path=/stitch/hls/channel/5efbd39f8c4ce900075d7698/master.m3u8
[utils.l10n][debug] Language code: en_US
[cli][info] Available streams: 570k (worst), 1000k, 1500k, 2100k, 3100k (best)
[cli][info] Opening stream: 3100k (hls-pluto)
[cli][info] Starting player: mpv
[stream.hls][debug] Reloading playlist
[cli][debug] Pre-buffering 8192 bytes
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] First Sequence: 45; Last Sequence: 49
[stream.hls][debug] Start offset: 0; Duration: None; Start Sequence: 47; End Sequence: None
[stream.hls][debug] Adding segment 47 to queue
[stream.hls][debug] Adding segment 48 to queue
[stream.hls][debug] Adding segment 49 to queue
[stream.hls][debug] Writing segment 47 to output
[stream.hls][debug] Segment 47 complete
[cli.output][debug] Opening subprocess: ['/usr/bin/mpv', '--force-media-title=https://pluto.tv/en/live-tv/star-trek-1', '-']
[stream.hls][debug] Writing segment 48 to output
[stream.hls][debug] Segment 48 complete
[cli][debug] Writing stream to output
[stream.hls][debug] Writing segment 49 to output
[stream.hls][debug] Segment 49 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 50 to queue
[stream.hls][debug] Writing segment 50 to output
[stream.hls][debug] Segment 50 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 51 to queue
[stream.hls][debug] Writing segment 51 to output
[stream.hls][debug] Segment 51 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 52 to queue
[stream.hls][debug] Writing segment 52 to output
[stream.hls][debug] Segment 52 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 53 to queue
[stream.hls][debug] Writing segment 53 to output
[stream.hls][debug] Segment 53 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 54 to queue
[stream.hls][debug] Writing segment 54 to output
[stream.hls][debug] Segment 54 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 55 to queue
[stream.hls][debug] Writing segment 55 to output
[stream.hls][debug] Segment 55 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 56 to queue
[stream.hls][debug] Writing segment 56 to output
[stream.hls][debug] Segment 56 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 57 to queue
[stream.hls][debug] Writing segment 57 to output
[stream.hls][debug] Segment 57 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 58 to queue
[stream.hls][debug] Writing segment 58 to output
[stream.hls][debug] Segment 58 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 59 to queue
[stream.hls][debug] Writing segment 59 to output
[stream.hls][debug] Segment 59 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 60 to queue
[stream.hls][debug] Writing segment 60 to output
[stream.hls][debug] Segment 60 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 61 to queue
[stream.hls][debug] Writing segment 61 to output
[stream.hls][debug] Segment 61 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 62 to queue
[stream.hls][debug] Writing segment 62 to output
[stream.hls][debug] Segment 62 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 63 to queue
[stream.hls][debug] Adding segment 64 to queue
[stream.hls][debug] Writing segment 63 to output
[stream.hls][debug] Segment 63 complete
[stream.hls][debug] Writing segment 64 to output
[stream.hls][debug] Segment 64 complete
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 65 to queue
[stream.hls][debug] Writing segment 65 to output
[stream.hls][debug] Segment 65 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 66 to queue
[stream.hls][debug] Writing segment 66 to output
[stream.hls][debug] Segment 66 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 67 to queue
[stream.hls][debug] Writing segment 67 to output
[stream.hls][debug] Segment 67 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 68 to queue
[stream.hls][debug] Writing segment 68 to output
[stream.hls][debug] Segment 68 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 69 to queue
[stream.hls][debug] Writing segment 69 to output
[stream.hls][debug] Segment 69 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 70 to queue
[stream.hls][debug] Writing segment 70 to output
[stream.hls][debug] Segment 70 complete
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 71 to queue
[stream.hls][debug] Writing segment 71 to output
[stream.hls][debug] Segment 71 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 72 to queue
[stream.hls][debug] Writing segment 72 to output
[stream.hls][debug] Segment 72 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 73 to queue
[stream.hls][debug] Discarding segment 73
[stream.hls][info] Filtering out segments and pausing stream output
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 74 to queue
[stream.hls][debug] Discarding segment 74
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 75 to queue
[stream.hls][debug] Discarding segment 75
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 76 to queue
[stream.hls][debug] Writing segment 76 to output
[stream.hls][debug] Segment 76 complete
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][info] Resuming stream output
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 77 to queue
[stream.hls][debug] Writing segment 77 to output
[stream.hls][debug] Segment 77 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 78 to queue
[stream.hls][debug] Writing segment 78 to output
[stream.hls][debug] Segment 78 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 79 to queue
[stream.hls][debug] Writing segment 79 to output
[stream.hls][debug] Segment 79 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 80 to queue
[stream.hls][debug] Writing segment 80 to output
[stream.hls][debug] Segment 80 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 81 to queue
[stream.hls][debug] Writing segment 81 to output
[stream.hls][debug] Segment 81 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 82 to queue
[stream.hls][debug] Discarding segment 82
[stream.hls][info] Filtering out segments and pausing stream output
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 83 to queue
[stream.hls][debug] Discarding segment 83
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 84 to queue
[stream.hls][debug] Discarding segment 84
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 85 to queue
[stream.hls][debug] Discarding segment 85
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 86 to queue
[stream.hls][debug] Discarding segment 86
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 87 to queue
[stream.hls][debug] Discarding segment 87
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 88 to queue
[stream.hls][debug] Discarding segment 88
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 89 to queue
[stream.hls][debug] Discarding segment 89
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 90 to queue
[stream.hls][debug] Discarding segment 90
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 91 to queue
[stream.hls][debug] Writing segment 91 to output
[stream.hls][debug] Segment 91 complete
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][info] Resuming stream output
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 92 to queue
[stream.hls][debug] Writing segment 92 to output
[stream.hls][debug] Segment 92 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 93 to queue
[stream.hls][debug] Writing segment 93 to output
[stream.hls][debug] Segment 93 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 94 to queue
[stream.hls][debug] Writing segment 94 to output
[stream.hls][debug] Segment 94 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] Adding segment 95 to queue
[cli][info] Player closed
[stream.segmented][debug] Closing worker thread
[stream.segmented][debug] Closing writer thread
[cli][info] Stream ended
[cli][info] Closing currently open stream…
```



Discussion:
This is the entire filtering logic of the Pluto plugin, which, as you can see, is a simple regex for specific HLS segment URLs when are then skipped in Streamlink's output:
https://github.com/streamlink/streamlink/blame/6.5.0/src/streamlink/plugins/pluto.py#L25-L29

If some ad segments don't get filtered out anymore for a couple of weeks now, then this means that the site has added new/different ad segments, and the regex needs to be adjusted. However, if there's additional metadata in the HLS playlist that could be used to filter out ads (similar to what the Twitch plugin is doing), then this would be the better choice, because the filtering logic would not depend on segment URLs anymore. Since I don't use this site/plugin, I can't tell you if that's the case though.

See the [`--loglevel=all`](https://streamlink.github.io/cli.html#cmdoption-loglevel) log level, which will output the entire HLS playlist content on each refresh, so you're able to see the segment URLs and other metadata. Setting [`--logfile=PATH`](https://streamlink.github.io/cli.html#cmdoption-logfile) is usually a good idea when increasing the log verbosity.

Resolving patch:
diff --git a/src/streamlink/plugins/pluto.py b/src/streamlink/plugins/pluto.py
--- a/src/streamlink/plugins/pluto.py
+++ b/src/streamlink/plugins/pluto.py
@@ -13,8 +13,9 @@
 from urllib.parse import parse_qsl, urljoin
 from uuid import uuid4
 
+from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin, pluginmatcher
-from streamlink.plugin.api import validate
+from streamlink.plugin.api import useragents, validate
 from streamlink.stream.hls import HLSStream, HLSStreamReader, HLSStreamWriter
 from streamlink.utils.url import update_qsd
 
@@ -23,7 +24,7 @@
 
 
 class PlutoHLSStreamWriter(HLSStreamWriter):
-    ad_re = re.compile(r"_ad/creative/|dai\.google\.com|Pluto_TV_OandO/.*(Bumper|plutotv_filler)")
+    ad_re = re.compile(r"_ad/creative/|creative/\d+_ad/|dai\.google\.com|Pluto_TV_OandO/.*(Bumper|plutotv_filler)")
 
     def should_filter_segment(self, segment):
         return self.ad_re.search(segment.uri) is not None or super().should_filter_segment(segment)
@@ -38,152 +39,192 @@ class PlutoHLSStream(HLSStream):
     __reader__ = PlutoHLSStreamReader
 
 
-@pluginmatcher(re.compile(r"""
-    https?://(?:www\.)?pluto\.tv/(?:\w{2}/)?(?:
-        live-tv/(?P<slug_live>[^/]+)
-        |
-        on-demand/series/(?P<slug_series>[^/]+)(?:/season/\d+)?/episode/(?P<slug_episode>[^/]+)
-        |
-        on-demand/movies/(?P<slug_movies>[^/]+)
-    )/?$
-""", re.VERBOSE))
+@pluginmatcher(
+    name="live",
+    pattern=re.compile(
+        r"https?://(?:www\.)?pluto\.tv/(?:\w{2}/)?live-tv/(?P<id>[^/]+)/?$",
+    ),
+)
+@pluginmatcher(
+    name="series",
+    pattern=re.compile(
+        r"https?://(?:www\.)?pluto\.tv/(?:\w{2}/)?on-demand/series/(?P<id_s>[^/]+)(?:/season/\d+)?/episode/(?P<id_e>[^/]+)/?$",
+    ),
+)
+@pluginmatcher(
+    name="movies",
+    pattern=re.compile(
+        r"https?://(?:www\.)?pluto\.tv/(?:\w{2}/)?on-demand/movies/(?P<id>[^/]+)/?$",
+    ),
+)
 class Pluto(Plugin):
-    def _get_api_data(self, kind, slug, slugfilter=None):
-        log.debug(f"slug={slug}")
-        app_version = self.session.http.get(self.url, schema=validate.Schema(
-            validate.parse_html(),
-            validate.xml_xpath_string(".//head/meta[@name='appVersion']/@content"),
-            validate.any(None, str),
-        ))
-        if not app_version:
-            return
-
-        log.debug(f"app_version={app_version}")
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.session.http.headers.update({"User-Agent": useragents.FIREFOX})
+        self._app_version = None
+        self._device_version = re.search(r"Firefox/(\d+(?:\.\d+)*)", useragents.FIREFOX)[1]
+        self._client_id = str(uuid4())
+
+    @property
+    def app_version(self):
+        if self._app_version:
+            return self._app_version
+
+        self._app_version = self.session.http.get(
+            self.url,
+            schema=validate.Schema(
+                validate.parse_html(),
+                validate.xml_xpath_string(".//head/meta[@name='appVersion']/@content"),
+                validate.any(None, str),
+            ),
+        )
+        if not self._app_version:
+            raise PluginError("Could not find pluto app version")
+
+        log.debug(f"{self._app_version=}")
+
+        return self._app_version
+
+    def _get_api_data(self, request):
+        log.debug(f"_get_api_data: {request=}")
+
+        schema_paths = validate.any(
+            validate.all(
+                {
+                    "paths": [
+                        validate.all(
+                            {
+                                "type": str,
+                                "path": str,
+                            },
+                            validate.union_get("type", "path"),
+                        ),
+                    ],
+                },
+                validate.get("paths"),
+            ),
+            validate.all(
+                {
+                    "path": str,
+                },
+                validate.transform(lambda obj: [("hls", obj["path"])]),
+            ),
+        )
+        schema_live = [{
+            "name": str,
+            "id": str,
+            "slug": str,
+            "stitched": schema_paths,
+        }]
+        schema_vod = [{
+            "name": str,
+            "id": str,
+            "slug": str,
+            "genre": str,
+            "stitched": validate.any(schema_paths, {}),
+            validate.optional("seasons"): [{
+                "episodes": [{
+                    "name": str,
+                    "_id": str,
+                    "slug": str,
+                    "stitched": schema_paths,
+                }],
+            }],
+        }]
 
         return self.session.http.get(
             "https://boot.pluto.tv/v4/start",
             params={
                 "appName": "web",
-                "appVersion": app_version,
-                "deviceVersion": "94.0.0",
+                "appVersion": self.app_version,
+                "deviceVersion": self._device_version,
                 "deviceModel": "web",
                 "deviceMake": "firefox",
                 "deviceType": "web",
-                "clientID": str(uuid4()),
-                "clientModelNumber": "1.0",
-                kind: slug,
+                "clientID": self._client_id,
+                "clientModelNumber": "1.0.0",
+                **request,
             },
             schema=validate.Schema(
-                validate.parse_json(), {
+                validate.parse_json(),
+                {
                     "servers": {
                         "stitcher": validate.url(),
                     },
-                    validate.optional("EPG"): [{
-                        "name": str,
-                        "id": str,
-                        "slug": str,
-                        "stitched": {
-                            "path": str,
-                        },
-                    }],
-                    validate.optional("VOD"): [{
-                        "name": str,
-                        "id": str,
-                        "slug": str,
-                        "genre": str,
-                        "stitched": {
-                            "path": str,
-                        },
-                        validate.optional("seasons"): [{
-                            "episodes": validate.all(
-                                [{
-                                    "name": str,
-                                    "_id": str,
-                                    "slug": str,
-                                    "stitched": {
-                                        "path": str,
-                                    },
-                                }],
-                                validate.filter(lambda k: slugfilter and k["slug"] == slugfilter),
-                            ),
-                        }],
-                    }],
-                    "sessionToken": str,
                     "stitcherParams": str,
+                    "sessionToken": str,
+                    validate.optional("EPG"): schema_live,
+                    validate.optional("VOD"): schema_vod,
                 },
             ),
         )
 
-    def _get_playlist(self, host, path, params, token):
-        qsd = dict(parse_qsl(params))
-        qsd["jwt"] = token
+    def _get_streams_live(self):
+        data = self._get_api_data({"channelSlug": self.match["id"]})
+        epg = data.get("EPG", [])
+        media = next((e for e in epg if e["id"] == self.match["id"]), None)
+        if not media:
+            return
+
+        self.id = media["id"]
+        self.title = media["name"]
+
+        return data, media["stitched"]
 
-        url = urljoin(host, path)
-        url = update_qsd(url, qsd)
+    def _get_streams_series(self):
+        data = self._get_api_data({"seriesIDs": self.match["id_s"]})
+        vod = data.get("VOD", [])
+        media = next((v for v in vod if v["id"] == self.match["id_s"]), None)
+        if not media:
+            return
+        seasons = media.get("seasons", [])
+        episode = next((e for s in seasons for e in s["episodes"] if e["_id"] == self.match["id_e"]), None)
+        if not episode:
+            return
 
-        return PlutoHLSStream.parse_variant_playlist(self.session, url)
+        self.id = episode["_id"]
+        self.author = media["name"]
+        self.category = media["genre"]
+        self.title = episode["name"]
 
-    @staticmethod
-    def _get_media_data(data, key, slug):
-        media = data.get(key)
-        if media and media[0]["slug"] == slug:
-            return media[0]
+        return data, episode["stitched"]
+
+    def _get_streams_movies(self):
+        data = self._get_api_data({"seriesIDs": self.match["id"]})
+        vod = data.get("VOD", [])
+        media = next((v for v in vod if v["id"] == self.match["id"]), None)
+        if not media:
+            return
+
+        self.id = media["id"]
+        self.category = media["genre"]
+        self.title = media["name"]
+
+        return data, media["stitched"]
 
     def _get_streams(self):
-        m = self.match.groupdict()
-        if m["slug_live"]:
-            data = self._get_api_data("channelSlug", m["slug_live"])
-            media = self._get_media_data(data, "EPG", m["slug_live"])
-            if not media:
-                return
-
-            self.id = media["id"]
-            self.title = media["name"]
-            path = media["stitched"]["path"]
-
-        elif m["slug_series"] and m["slug_episode"]:
-            data = self._get_api_data("episodeSlugs", m["slug_series"], slugfilter=m["slug_episode"])
-            media = self._get_media_data(data, "VOD", m["slug_series"])
-            if not media or "seasons" not in media:
-                return
-
-            for season in media["seasons"]:
-                if season["episodes"]:
-                    episode = season["episodes"][0]
-                    if episode["slug"] == m["slug_episode"]:
-                        break
-            else:
-                return
-
-            self.author = media["name"]
-            self.category = media["genre"]
-            self.id = episode["_id"]
-            self.title = episode["name"]
-            path = episode["stitched"]["path"]
-
-        elif m["slug_movies"]:
-            data = self._get_api_data("episodeSlugs", m["slug_movies"])
-            media = self._get_media_data(data, "VOD", m["slug_movies"])
-            if not media:
-                return
-
-            self.category = media["genre"]
-            self.id = media["id"]
-            self.title = media["name"]
-            path = media["stitched"]["path"]
-
-        else:
+        res = None
+        if self.matches["live"]:
+            res = self._get_streams_live()
+        elif self.matches["series"]:
+            res = self._get_streams_series()
+        elif self.matches["movies"]:
+            res = self._get_streams_movies()
+
+        if not res:
             return
 
-        log.trace(f"data={data!r}")
-        log.debug(f"path={path}")
+        data, paths = res
+        for mediatype, path in paths:
+            if mediatype != "hls":
+                continue
 
-        return self._get_playlist(
-            data["servers"]["stitcher"],
-            path,
-            data["stitcherParams"],
-            data["sessionToken"],
-        )
+            params = dict(parse_qsl(data["stitcherParams"]))
+            params["jwt"] = data["sessionToken"]
+            url = urljoin(data["servers"]["stitcher"], path)
+            url = update_qsd(url, params)
+
+            return PlutoHLSStream.parse_variant_playlist(self.session, url)
 
 
 __plugin__ = Pluto


Test patch:
diff --git a/tests/plugins/test_pluto.py b/tests/plugins/test_pluto.py
--- a/tests/plugins/test_pluto.py
+++ b/tests/plugins/test_pluto.py
@@ -5,56 +5,28 @@
 class TestPluginCanHandleUrlPluto(PluginCanHandleUrl):
     __plugin__ = Pluto
 
-    should_match = [
-        "http://www.pluto.tv/live-tv/channel-lineup",
-        "http://pluto.tv/live-tv/channel",
-        "http://pluto.tv/live-tv/channel/",
-        "https://pluto.tv/live-tv/red-bull-tv-2",
-        "https://pluto.tv/live-tv/4k-tv",
-        "http://www.pluto.tv/on-demand/series/leverage/season/1/episode/the-nigerian-job-2009-1-1",
-        "http://pluto.tv/on-demand/series/fear-factor-usa-(lf)/season/5/episode/underwater-safe-bob-car-ramp-2004-5-3",
-        "https://www.pluto.tv/on-demand/movies/dr.-no-1963-1-1",
-        "http://pluto.tv/on-demand/movies/the-last-dragon-(1985)-1-1",
-        "http://www.pluto.tv/lc/live-tv/channel-lineup",
-        "http://pluto.tv/lc/live-tv/channel",
-        "http://pluto.tv/lc/live-tv/channel/",
-        "https://pluto.tv/lc/live-tv/red-bull-tv-2",
-        "https://pluto.tv/lc/live-tv/4k-tv",
-        "http://www.pluto.tv/lc/on-demand/series/leverage/season/1/episode/the-nigerian-job-2009-1-1",
-        "http://pluto.tv/lc/on-demand/series/fear-factor-usa-(lf)/season/5/episode/underwater-safe-bob-car-ramp-2004-5-3",
-        "https://www.pluto.tv/lc/on-demand/movies/dr.-no-1963-1-1",
-        "https://www.pluto.tv/lc/on-demand/movies/dr.-no-1963-1-1/",
-        "http://pluto.tv/lc/on-demand/movies/the-last-dragon-(1985)-1-1",
-        "http://pluto.tv/lc/on-demand/movies/the-last-dragon-(1985)-1-1/",
-        "https://pluto.tv/en/on-demand/series/great-british-menu-ptv1/episode/north-west-fish-2009-5-7-ptv1",
-        "https://pluto.tv/en/on-demand/series/great-british-menu-ptv1/episode/north-west-fish-2009-5-7-ptv1/",
-        "https://www.pluto.tv/en/on-demand/series/great-british-menu-ptv1/episode/north-west-fish-2009-5-7-ptv1",
-        "https://www.pluto.tv/en/on-demand/series/great-british-menu-ptv1/episode/north-west-fish-2009-5-7-ptv1/",
+    should_match_groups = [
+        (
+            ("live", "https://pluto.tv/en/live-tv/61409f8d6feb30000766b675"),
+            {"id": "61409f8d6feb30000766b675"},
+        ),
+
+        (
+            ("series", "https://pluto.tv/en/on-demand/series/5e00cd538e67b0dcb2cf3bcd/season/1/episode/60dee91cfc802600134b886d"),
+            {"id_s": "5e00cd538e67b0dcb2cf3bcd", "id_e": "60dee91cfc802600134b886d"},
+        ),
+
+        (
+            ("movies", "https://pluto.tv/en/on-demand/movies/600545d1813b2d001b686fa9"),
+            {"id": "600545d1813b2d001b686fa9"},
+        ),
     ]
 
     should_not_match = [
-        "https://fake.pluto.tv/live-tv/hello",
-        "http://www.pluto.tv/live-tv/channel-lineup/extra",
-        "https://www.pluto.tv/live-tv",
         "https://pluto.tv/live-tv",
-        "https://www.pluto.com/live-tv/swag",
-        "http://pluto.tv/movies/dr.-no-1963-1-1",
-        "http://pluto.tv/on-demand/movies/dr.-no-1/963-1-1",
-        "http://pluto.tv/on-demand/series/dr.-no-1963-1-1",
-        "http://pluto.tv/on-demand/movies/leverage/season/1/episode/the-nigerian-job-2009-1-1",
-        "http://pluto.tv/on-demand/fear-factor-usa-(lf)/season/5/episode/underwater-safe-bob-car-ramp-2004-5-3",
-        "https://fake.pluto.tv/lc/live-tv/hello",
-        "http://www.pluto.tv/lc/live-tv/channel-lineup/extra",
-        "https://www.pluto.tv/lc/live-tv",
-        "https://pluto.tv/lc/live-tv",
-        "https://www.pluto.com/lc/live-tv/swag",
-        "http://pluto.tv/lc/movies/dr.-no-1963-1-1",
-        "http://pluto.tv/lc/on-demand/movies/dr.-no-1/963-1-1",
-        "http://pluto.tv/lc/on-demand/series/dr.-no-1963-1-1",
-        "http://pluto.tv/lc/on-demand/movies/leverage/season/1/episode/the-nigerian-job-2009-1-1",
-        "http://pluto.tv/lc/on-demand/fear-factor-usa-(lf)/season/5/episode/underwater-safe-bob-car-ramp-2004-5-3",
-        "https://pluto.tv/en/on-demand/series/great-british-menu-ptv1/episode/north-west-fish-2009-5-7-ptv1/extra",
-        "https://pluto.tv/en/on-demand/series/great-british-menu-ptv1/season/5/episode/north-west-fish-2009-5-7-ptv1/extra",
-        "https://www.pluto.tv/en/on-demand/series/great-british-menu-ptv1/episode/north-west-fish-2009-5-7-ptv1/extra",
-        "https://www.pluto.tv/en/on-demand/series/great-british-menu-ptv1/season/5/episode/north-west-fish-2009-5-7-ptv1/extra",
+        "https://pluto.tv/en/live-tv/61409f8d6feb30000766b675/details",
+
+        "https://pluto.tv/en/on-demand/series/5e00cd538e67b0dcb2cf3bcd/details/season/1",
+
+        "https://pluto.tv/en/on-demand/movies/600545d1813b2d001b686fa9/details",
     ]