13
13
from urllib .parse import parse_qsl , urljoin
14
14
from uuid import uuid4
15
15
16
+ from streamlink .exceptions import PluginError
16
17
from streamlink .plugin import Plugin , pluginmatcher
17
- from streamlink .plugin .api import validate
18
+ from streamlink .plugin .api import useragents , validate
18
19
from streamlink .stream .hls import HLSStream , HLSStreamReader , HLSStreamWriter
19
20
from streamlink .utils .url import update_qsd
20
21
23
24
24
25
25
26
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)" )
27
28
28
29
def should_filter_segment (self , segment ):
29
30
return self .ad_re .search (segment .uri ) is not None or super ().should_filter_segment (segment )
@@ -38,152 +39,192 @@ class PlutoHLSStream(HLSStream):
38
39
__reader__ = PlutoHLSStreamReader
39
40
40
41
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
+ )
50
60
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
+ }]
62
134
63
135
return self .session .http .get (
64
136
"https://boot.pluto.tv/v4/start" ,
65
137
params = {
66
138
"appName" : "web" ,
67
- "appVersion" : app_version ,
68
- "deviceVersion" : "94.0.0" ,
139
+ "appVersion" : self . app_version ,
140
+ "deviceVersion" : self . _device_version ,
69
141
"deviceModel" : "web" ,
70
142
"deviceMake" : "firefox" ,
71
143
"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 ,
75
147
},
76
148
schema = validate .Schema (
77
- validate .parse_json (), {
149
+ validate .parse_json (),
150
+ {
78
151
"servers" : {
79
152
"stitcher" : validate .url (),
80
153
},
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 ,
112
154
"stitcherParams" : str ,
155
+ "sessionToken" : str ,
156
+ validate .optional ("EPG" ): schema_live ,
157
+ validate .optional ("VOD" ): schema_vod ,
113
158
},
114
159
),
115
160
)
116
161
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" ]
120
173
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
123
184
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" ]
125
189
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" ]
131
204
132
205
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 :
176
215
return
177
216
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
180
221
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 )
187
228
188
229
189
230
__plugin__ = Pluto
0 commit comments