Skip to content

Commit 735bd5f

Browse files
committed
plugins.pluto: rewrite plugin
1 parent f87bbd5 commit 735bd5f

File tree

2 files changed

+182
-169
lines changed

2 files changed

+182
-169
lines changed

src/streamlink/plugins/pluto.py

Lines changed: 162 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
from urllib.parse import parse_qsl, urljoin
1414
from uuid import uuid4
1515

16+
from streamlink.exceptions import PluginError
1617
from streamlink.plugin import Plugin, pluginmatcher
17-
from streamlink.plugin.api import validate
18+
from streamlink.plugin.api import useragents, validate
1819
from streamlink.stream.hls import HLSStream, HLSStreamReader, HLSStreamWriter
1920
from streamlink.utils.url import update_qsd
2021

@@ -23,7 +24,7 @@
2324

2425

2526
class PlutoHLSStreamWriter(HLSStreamWriter):
26-
ad_re = re.compile(r"_ad/creative/|dai\.google\.com|Pluto_TV_OandO/.*(Bumper|plutotv_filler)")
27+
ad_re = re.compile(r"_ad/creative/|creative/\d+_ad/|dai\.google\.com|Pluto_TV_OandO/.*(Bumper|plutotv_filler)")
2728

2829
def should_filter_segment(self, segment):
2930
return self.ad_re.search(segment.uri) is not None or super().should_filter_segment(segment)
@@ -38,152 +39,192 @@ class PlutoHLSStream(HLSStream):
3839
__reader__ = PlutoHLSStreamReader
3940

4041

41-
@pluginmatcher(re.compile(r"""
42-
https?://(?:www\.)?pluto\.tv/(?:\w{2}/)?(?:
43-
live-tv/(?P<slug_live>[^/]+)
44-
|
45-
on-demand/series/(?P<slug_series>[^/]+)(?:/season/\d+)?/episode/(?P<slug_episode>[^/]+)
46-
|
47-
on-demand/movies/(?P<slug_movies>[^/]+)
48-
)/?$
49-
""", re.VERBOSE))
42+
@pluginmatcher(
43+
name="live",
44+
pattern=re.compile(
45+
r"https?://(?:www\.)?pluto\.tv/(?:\w{2}/)?live-tv/(?P<id>[^/]+)/?$",
46+
),
47+
)
48+
@pluginmatcher(
49+
name="series",
50+
pattern=re.compile(
51+
r"https?://(?:www\.)?pluto\.tv/(?:\w{2}/)?on-demand/series/(?P<id_s>[^/]+)(?:/season/\d+)?/episode/(?P<id_e>[^/]+)/?$",
52+
),
53+
)
54+
@pluginmatcher(
55+
name="movies",
56+
pattern=re.compile(
57+
r"https?://(?:www\.)?pluto\.tv/(?:\w{2}/)?on-demand/movies/(?P<id>[^/]+)/?$",
58+
),
59+
)
5060
class Pluto(Plugin):
51-
def _get_api_data(self, kind, slug, slugfilter=None):
52-
log.debug(f"slug={slug}")
53-
app_version = self.session.http.get(self.url, schema=validate.Schema(
54-
validate.parse_html(),
55-
validate.xml_xpath_string(".//head/meta[@name='appVersion']/@content"),
56-
validate.any(None, str),
57-
))
58-
if not app_version:
59-
return
60-
61-
log.debug(f"app_version={app_version}")
61+
def __init__(self, *args, **kwargs):
62+
super().__init__(*args, **kwargs)
63+
self.session.http.headers.update({"User-Agent": useragents.FIREFOX})
64+
self._app_version = None
65+
self._device_version = re.search(r"Firefox/(\d+(?:\.\d+)*)", useragents.FIREFOX)[1]
66+
self._client_id = str(uuid4())
67+
68+
@property
69+
def app_version(self):
70+
if self._app_version:
71+
return self._app_version
72+
73+
self._app_version = self.session.http.get(
74+
self.url,
75+
schema=validate.Schema(
76+
validate.parse_html(),
77+
validate.xml_xpath_string(".//head/meta[@name='appVersion']/@content"),
78+
validate.any(None, str),
79+
),
80+
)
81+
if not self._app_version:
82+
raise PluginError("Could not find pluto app version")
83+
84+
log.debug(f"{self._app_version=}")
85+
86+
return self._app_version
87+
88+
def _get_api_data(self, request):
89+
log.debug(f"_get_api_data: {request=}")
90+
91+
schema_paths = validate.any(
92+
validate.all(
93+
{
94+
"paths": [
95+
validate.all(
96+
{
97+
"type": str,
98+
"path": str,
99+
},
100+
validate.union_get("type", "path"),
101+
),
102+
],
103+
},
104+
validate.get("paths"),
105+
),
106+
validate.all(
107+
{
108+
"path": str,
109+
},
110+
validate.transform(lambda obj: [("hls", obj["path"])]),
111+
),
112+
)
113+
schema_live = [{
114+
"name": str,
115+
"id": str,
116+
"slug": str,
117+
"stitched": schema_paths,
118+
}]
119+
schema_vod = [{
120+
"name": str,
121+
"id": str,
122+
"slug": str,
123+
"genre": str,
124+
"stitched": validate.any(schema_paths, {}),
125+
validate.optional("seasons"): [{
126+
"episodes": [{
127+
"name": str,
128+
"_id": str,
129+
"slug": str,
130+
"stitched": schema_paths,
131+
}],
132+
}],
133+
}]
62134

63135
return self.session.http.get(
64136
"https://boot.pluto.tv/v4/start",
65137
params={
66138
"appName": "web",
67-
"appVersion": app_version,
68-
"deviceVersion": "94.0.0",
139+
"appVersion": self.app_version,
140+
"deviceVersion": self._device_version,
69141
"deviceModel": "web",
70142
"deviceMake": "firefox",
71143
"deviceType": "web",
72-
"clientID": str(uuid4()),
73-
"clientModelNumber": "1.0",
74-
kind: slug,
144+
"clientID": self._client_id,
145+
"clientModelNumber": "1.0.0",
146+
**request,
75147
},
76148
schema=validate.Schema(
77-
validate.parse_json(), {
149+
validate.parse_json(),
150+
{
78151
"servers": {
79152
"stitcher": validate.url(),
80153
},
81-
validate.optional("EPG"): [{
82-
"name": str,
83-
"id": str,
84-
"slug": str,
85-
"stitched": {
86-
"path": str,
87-
},
88-
}],
89-
validate.optional("VOD"): [{
90-
"name": str,
91-
"id": str,
92-
"slug": str,
93-
"genre": str,
94-
"stitched": {
95-
"path": str,
96-
},
97-
validate.optional("seasons"): [{
98-
"episodes": validate.all(
99-
[{
100-
"name": str,
101-
"_id": str,
102-
"slug": str,
103-
"stitched": {
104-
"path": str,
105-
},
106-
}],
107-
validate.filter(lambda k: slugfilter and k["slug"] == slugfilter),
108-
),
109-
}],
110-
}],
111-
"sessionToken": str,
112154
"stitcherParams": str,
155+
"sessionToken": str,
156+
validate.optional("EPG"): schema_live,
157+
validate.optional("VOD"): schema_vod,
113158
},
114159
),
115160
)
116161

117-
def _get_playlist(self, host, path, params, token):
118-
qsd = dict(parse_qsl(params))
119-
qsd["jwt"] = token
162+
def _get_streams_live(self):
163+
data = self._get_api_data({"channelSlug": self.match["id"]})
164+
epg = data.get("EPG", [])
165+
media = next((e for e in epg if e["id"] == self.match["id"]), None)
166+
if not media:
167+
return
168+
169+
self.id = media["id"]
170+
self.title = media["name"]
171+
172+
return data, media["stitched"]
120173

121-
url = urljoin(host, path)
122-
url = update_qsd(url, qsd)
174+
def _get_streams_series(self):
175+
data = self._get_api_data({"seriesIDs": self.match["id_s"]})
176+
vod = data.get("VOD", [])
177+
media = next((v for v in vod if v["id"] == self.match["id_s"]), None)
178+
if not media:
179+
return
180+
seasons = media.get("seasons", [])
181+
episode = next((e for s in seasons for e in s["episodes"] if e["_id"] == self.match["id_e"]), None)
182+
if not episode:
183+
return
123184

124-
return PlutoHLSStream.parse_variant_playlist(self.session, url)
185+
self.id = episode["_id"]
186+
self.author = media["name"]
187+
self.category = media["genre"]
188+
self.title = episode["name"]
125189

126-
@staticmethod
127-
def _get_media_data(data, key, slug):
128-
media = data.get(key)
129-
if media and media[0]["slug"] == slug:
130-
return media[0]
190+
return data, episode["stitched"]
191+
192+
def _get_streams_movies(self):
193+
data = self._get_api_data({"seriesIDs": self.match["id"]})
194+
vod = data.get("VOD", [])
195+
media = next((v for v in vod if v["id"] == self.match["id"]), None)
196+
if not media:
197+
return
198+
199+
self.id = media["id"]
200+
self.category = media["genre"]
201+
self.title = media["name"]
202+
203+
return data, media["stitched"]
131204

132205
def _get_streams(self):
133-
m = self.match.groupdict()
134-
if m["slug_live"]:
135-
data = self._get_api_data("channelSlug", m["slug_live"])
136-
media = self._get_media_data(data, "EPG", m["slug_live"])
137-
if not media:
138-
return
139-
140-
self.id = media["id"]
141-
self.title = media["name"]
142-
path = media["stitched"]["path"]
143-
144-
elif m["slug_series"] and m["slug_episode"]:
145-
data = self._get_api_data("episodeSlugs", m["slug_series"], slugfilter=m["slug_episode"])
146-
media = self._get_media_data(data, "VOD", m["slug_series"])
147-
if not media or "seasons" not in media:
148-
return
149-
150-
for season in media["seasons"]:
151-
if season["episodes"]:
152-
episode = season["episodes"][0]
153-
if episode["slug"] == m["slug_episode"]:
154-
break
155-
else:
156-
return
157-
158-
self.author = media["name"]
159-
self.category = media["genre"]
160-
self.id = episode["_id"]
161-
self.title = episode["name"]
162-
path = episode["stitched"]["path"]
163-
164-
elif m["slug_movies"]:
165-
data = self._get_api_data("episodeSlugs", m["slug_movies"])
166-
media = self._get_media_data(data, "VOD", m["slug_movies"])
167-
if not media:
168-
return
169-
170-
self.category = media["genre"]
171-
self.id = media["id"]
172-
self.title = media["name"]
173-
path = media["stitched"]["path"]
174-
175-
else:
206+
res = None
207+
if self.matches["live"]:
208+
res = self._get_streams_live()
209+
elif self.matches["series"]:
210+
res = self._get_streams_series()
211+
elif self.matches["movies"]:
212+
res = self._get_streams_movies()
213+
214+
if not res:
176215
return
177216

178-
log.trace(f"data={data!r}")
179-
log.debug(f"path={path}")
217+
data, paths = res
218+
for mediatype, path in paths:
219+
if mediatype != "hls":
220+
continue
180221

181-
return self._get_playlist(
182-
data["servers"]["stitcher"],
183-
path,
184-
data["stitcherParams"],
185-
data["sessionToken"],
186-
)
222+
params = dict(parse_qsl(data["stitcherParams"]))
223+
params["jwt"] = data["sessionToken"]
224+
url = urljoin(data["servers"]["stitcher"], path)
225+
url = update_qsd(url, params)
226+
227+
return PlutoHLSStream.parse_variant_playlist(self.session, url)
187228

188229

189230
__plugin__ = Pluto

0 commit comments

Comments
 (0)