88 determine_ext ,
99 int_or_none ,
1010 js_to_json ,
11+ make_archive_id ,
1112 parse_duration ,
1213 parse_iso8601 ,
1314 url_or_none ,
1617
1718
1819class RTPIE (InfoExtractor ):
19- _VALID_URL = r'https?://(?:www\.)?rtp\.pt/play/(?:[^/#?]+/)?p (?P<program_id>\d+)/(?P<id >e\d+)'
20+ _VALID_URL = r'https?://(?:www\.)?rtp\.pt/play/(?:[^/#?]+/)?(?P<program_id>p \d+)/(?P<episode_id >e\d+)(?:/[^/#?]+/(?P<asset_id>\d+))? '
2021 _TESTS = [{
2122 'url' : 'http://www.rtp.pt/play/p405/e174042/paixoes-cruzadas' ,
2223 'md5' : 'e736ce0c665e459ddb818546220b4ef8' ,
2324 'info_dict' : {
24- 'id' : 'e174042 ' ,
25+ 'id' : '395769 ' ,
2526 'ext' : 'mp3' ,
2627 'title' : 'Paixões Cruzadas' ,
2728 'description' : 'md5:af979e58ba0ab73f78435fc943fdb070' ,
@@ -32,12 +33,15 @@ class RTPIE(InfoExtractor):
3233 'modified_date' : '20190327' ,
3334 'timestamp' : 1417219200 ,
3435 'upload_date' : '20141129' ,
36+ 'episode_id' : 'e174042' ,
37+ 'series_id' : 'p405' ,
38+ '_old_archive_ids' : ['rtp e174042' ],
3539 },
3640 }, {
3741 'url' : 'https://www.rtp.pt/play/zigzag/p13166/e757904/25-curiosidades-25-de-abril' ,
3842 'md5' : '5b4859940e3adef61247a77dfb76046a' ,
3943 'info_dict' : {
40- 'id' : 'e757904 ' ,
44+ 'id' : '1226642 ' ,
4145 'ext' : 'mp4' ,
4246 'title' : 'Estudar ou não estudar' ,
4347 'description' : 'md5:3bfd7eb8bebfd5711a08df69c9c14c35' ,
@@ -50,20 +54,84 @@ class RTPIE(InfoExtractor):
5054 'episode_number' : 2 ,
5155 'episode' : 'Estudar ou não estudar' ,
5256 'modified_date' : '20240404' ,
57+ 'episode_id' : 'e757904' ,
58+ 'series_id' : 'p13166' ,
59+ '_old_archive_ids' : ['rtp e757904' ],
5360 },
5461 }, {
5562 # Episode not accessible through API
5663 'url' : 'https://www.rtp.pt/play/estudoemcasa/p7776/e500050/portugues-1-ano' ,
5764 'md5' : '57660c0b46db9f22118c52cbd65975e4' ,
5865 'info_dict' : {
59- 'id' : 'e500050 ' ,
66+ 'id' : '871639 ' ,
6067 'ext' : 'mp4' ,
6168 'title' : 'Português - 1.º ano' ,
6269 'duration' : 1669.0 ,
6370 'description' : 'md5:be68925c81269f8c6886589f25fe83ea' ,
6471 'upload_date' : '20201020' ,
6572 'timestamp' : 1603180799 ,
6673 'thumbnail' : 'https://cdn-images.rtp.pt/EPG/imagens/39482_59449_64850.png?v=3&w=860' ,
74+ 'episode_id' : 'e500050' ,
75+ 'series_id' : 'p7776' ,
76+ '_old_archive_ids' : ['rtp e500050' ],
77+ },
78+ 'expected_warnings' : ['Episode data not found in API response; falling back to web extraction' ],
79+ }, {
80+ # Ambiguous URL for 1st part of a multi-part episode without --no-playlist
81+ 'url' : 'https://www.rtp.pt/play/p14335/e877072/a-nossa-tarde' ,
82+ 'info_dict' : {
83+ 'id' : 'e877072' ,
84+ 'title' : 'A Nossa Tarde' ,
85+ 'duration' : 6545.0 ,
86+ 'series' : 'A Nossa Tarde' ,
87+ 'series_id' : 'p14335' ,
88+ 'season' : '2025' ,
89+ 'episode_id' : 'e877072' ,
90+ 'timestamp' : 1758560188 ,
91+ 'upload_date' : '20250922' ,
92+ 'modified_timestamp' : 1758563110 ,
93+ 'modified_date' : '20250922' ,
94+ },
95+ 'playlist_count' : 3 ,
96+ }, {
97+ # Ambiguous URL for 1st part of a multi-part episode with --no-playlist
98+ 'url' : 'https://www.rtp.pt/play/p14335/e877072/a-nossa-tarde' ,
99+ 'md5' : '2aa3c89c95e852d6f04168b95d0d0632' ,
100+ 'info_dict' : {
101+ 'id' : '1364711' ,
102+ 'ext' : 'mp4' ,
103+ 'title' : 'A Nossa Tarde' ,
104+ 'duration' : 1292.0 ,
105+ 'thumbnail' : r're:https://cdn-images\.rtp\.pt/multimedia/screenshots/p14335/p14335_1_20250922155118e161t0312\.jpg' ,
106+ 'series' : 'A Nossa Tarde' ,
107+ 'series_id' : 'p14335' ,
108+ 'season' : '2025' ,
109+ 'episode_id' : 'e877072' ,
110+ 'timestamp' : 1758560188 ,
111+ 'upload_date' : '20250922' ,
112+ 'modified_timestamp' : 1758563110 ,
113+ 'modified_date' : '20250922' ,
114+ '_old_archive_ids' : ['rtp e877072' ],
115+ },
116+ 'params' : {'noplaylist' : True },
117+ }, {
118+ # Unambiguous URL for 2nd part of a multi-part episode
119+ 'url' : 'https://www.rtp.pt/play/p14335/e877072/a-nossa-tarde/1364744' ,
120+ 'md5' : 'b624767af558a557372a6fcd1dcdfa17' ,
121+ 'info_dict' : {
122+ 'id' : '1364744' ,
123+ 'ext' : 'mp4' ,
124+ 'title' : 'A Nossa Tarde' ,
125+ 'duration' : 3270.0 ,
126+ 'thumbnail' : r're:https://cdn-images\.rtp\.pt/multimedia/screenshots/p14335/p14335_2_20250922165718e161t0412\.jpg' ,
127+ 'series' : 'A Nossa Tarde' ,
128+ 'series_id' : 'p14335' ,
129+ 'season' : '2025' ,
130+ 'episode_id' : 'e877072' ,
131+ 'timestamp' : 1758560188 ,
132+ 'upload_date' : '20250922' ,
133+ 'modified_timestamp' : 1758563110 ,
134+ 'modified_date' : '20250922' ,
67135 },
68136 }]
69137
@@ -92,19 +160,19 @@ def _cleanup_media_url(url):
92160 return None
93161 return url .replace ('/drm-fps/' , '/hls/' ).replace ('/drm-dash/' , '/dash/' )
94162
95- def _extract_formats (self , media_urls , episode_id ):
163+ def _extract_formats (self , media_urls , display_id ):
96164 formats = []
97165 subtitles = {}
98166 for media_url in set (traverse_obj (media_urls , (..., {url_or_none }, {self ._cleanup_media_url }))):
99167 ext = determine_ext (media_url )
100168 if ext == 'm3u8' :
101169 fmts , subs = self ._extract_m3u8_formats_and_subtitles (
102- media_url , episode_id , m3u8_id = 'hls' , fatal = False )
170+ media_url , display_id , m3u8_id = 'hls' , fatal = False )
103171 formats .extend (fmts )
104172 self ._merge_subtitles (subs , target = subtitles )
105173 elif ext == 'mpd' :
106174 fmts , subs = self ._extract_mpd_formats_and_subtitles (
107- media_url , episode_id , mpd_id = 'dash' , fatal = False )
175+ media_url , display_id , mpd_id = 'dash' , fatal = False )
108176 formats .extend (fmts )
109177 self ._merge_subtitles (subs , target = subtitles )
110178 else :
@@ -114,24 +182,12 @@ def _extract_formats(self, media_urls, episode_id):
114182 })
115183 return formats , subtitles
116184
117- def _extract_from_api (self , program_id , episode_id ):
118- auth_token = self ._fetch_auth_token ()
119- if not auth_token :
120- return
121- episode_data = traverse_obj (self ._download_json (
122- f'https://www.rtp.pt/play/api/1/get-episode/{ program_id } /{ episode_id [1 :]} ' , episode_id ,
123- query = {'include_assets' : 'true' , 'include_webparams' : 'true' },
124- headers = {
125- 'Accept' : '*/*' ,
126- 'Authorization' : f'Bearer { auth_token } ' ,
127- 'User-Agent' : self ._USER_AGENT ,
128- }, fatal = False ), 'result' , {dict })
129- if not episode_data :
130- return
131- asset_urls = traverse_obj (episode_data , ('assets' , 0 , 'asset_url' , {dict }))
185+ def _extract_asset (self , asset_data , episode_id , episode_info , archive_compat = False ):
186+ asset_id = asset_data ['asset_id' ]
187+ asset_urls = traverse_obj (asset_data , ('asset_url' , {dict }))
132188 media_urls = traverse_obj (asset_urls , (
133189 ((('hls' , 'dash' ), 'stream_url' ), ('multibitrate' , ('url_hls' , 'url_dash' ))),))
134- formats , subtitles = self ._extract_formats (media_urls , episode_id )
190+ formats , subtitles = self ._extract_formats (media_urls , asset_id )
135191
136192 for sub_data in traverse_obj (asset_urls , ('subtitles' , 'vtt_list' , lambda _ , v : url_or_none (v ['file' ]))):
137193 subtitles .setdefault (sub_data .get ('code' ) or 'pt' , []).append ({
@@ -140,24 +196,94 @@ def _extract_from_api(self, program_id, episode_id):
140196 })
141197
142198 return {
143- 'id' : episode_id ,
199+ ** episode_info ,
200+ 'id' : asset_id ,
201+ 'episode_id' : episode_id ,
202+ # asset_id is a unique identifier for all RTP videos, while episode_id is duplicated
203+ # across all parts of a multi-part episode. Older versions of this IE returned
204+ # episode_id as the video id and would only download the first part of multi-part eps.
205+ # For download archive compat, we should return the episode_id as the old archive id
206+ # *only* when extracting single-part episodes OR the *first* part of a multi-part ep.
207+ '_old_archive_ids' : [make_archive_id (self , episode_id )] if archive_compat else None ,
144208 'formats' : formats ,
145209 'subtitles' : subtitles ,
146- 'thumbnail' : traverse_obj (episode_data , ('assets' , 0 , 'asset_thumbnail' , {url_or_none })),
210+ ** traverse_obj (asset_data , {
211+ 'thumbnail' : ('asset_thumbnail' , {url_or_none }),
212+ 'duration' : ('asset_duration' , {parse_duration }),
213+ 'webpage_url' : ('web' , 'url' , {url_or_none }),
214+ }),
215+ }
216+
217+ def _report_fallback_warning (self , missing_info_name = 'required info' , display_id = None ):
218+ self .report_warning (
219+ f'{ missing_info_name .capitalize ()} not found in API response; falling back to web extraction' ,
220+ video_id = display_id )
221+
222+ def _entries (self , assets , episode_id , episode_info ):
223+ # Only pass archive_compat=True for the first entry without an asset_id in its webpage_url
224+ for idx , asset_data in enumerate (assets ):
225+ yield self ._extract_asset (asset_data , episode_id , episode_info , archive_compat = not idx )
226+
227+ def _extract_from_api (self , program_id , episode_id , asset_id ):
228+ auth_token = self ._fetch_auth_token ()
229+ if not auth_token :
230+ self ._report_fallback_warning ('auth token' , episode_id )
231+ return None
232+
233+ episode_data = traverse_obj (self ._download_json (
234+ f'https://www.rtp.pt/play/api/1/get-episode/{ program_id [1 :]} /{ episode_id [1 :]} ' ,
235+ asset_id or episode_id , query = {'include_assets' : 'true' , 'include_webparams' : 'true' },
236+ headers = {
237+ 'Accept' : '*/*' ,
238+ 'Authorization' : f'Bearer { auth_token } ' ,
239+ 'User-Agent' : self ._USER_AGENT ,
240+ }, fatal = False ), 'result' , {dict })
241+ if not episode_data :
242+ self ._report_fallback_warning ('episode data' , episode_id )
243+ return None
244+
245+ episode_info = {
246+ 'id' : episode_id , # playlist id
247+ 'episode_id' : episode_id ,
248+ 'series_id' : program_id ,
147249 ** traverse_obj (episode_data , ('episode' , {
148250 'title' : (('episode_title' , 'program_title' ), {str }, filter , any ),
149251 'alt_title' : ('episode_subtitle' , {str }, filter ),
150252 'description' : (('episode_description' , 'episode_summary' ), {str }, filter , any ),
151253 'timestamp' : ('episode_air_date' , {parse_iso8601 (delimiter = ' ' )}),
152254 'modified_timestamp' : ('episode_lastchanged' , {parse_iso8601 (delimiter = ' ' )}),
153- 'duration' : ('episode_duration_complete' , {parse_duration }),
255+ 'duration' : ('episode_duration_complete' , {parse_duration }), # playlist duration
154256 'episode' : ('episode_title' , {str }, filter ),
155257 'episode_number' : ('episode_number' , {int_or_none }),
156258 'season' : ('program_season' , {str }, filter ),
157259 'series' : ('program_title' , {str }, filter ),
158260 })),
159261 }
160262
263+ assets = traverse_obj (episode_data , ('assets' , lambda _ , v : v ['asset_id' ]))
264+ if not assets :
265+ self ._report_fallback_warning ('asset IDs' , episode_id )
266+ return None
267+
268+ if asset_id :
269+ asset_data = traverse_obj (assets , (lambda _ , v : v ['asset_id' ] == asset_id , any ))
270+ if not asset_data :
271+ self ._report_fallback_warning (f'asset { asset_id } ' , episode_id )
272+ return None
273+ return self ._extract_asset (asset_data , episode_id , episode_info )
274+
275+ asset_data = assets [0 ]
276+
277+ if self ._yes_playlist (
278+ len (assets ) > 1 and episode_id , asset_data ['asset_id' ],
279+ playlist_label = 'multi-part episode' , video_label = 'individual part' ,
280+ ):
281+ return self .playlist_result (
282+ self ._entries (assets , episode_id , episode_info ), ** episode_info )
283+
284+ # Pass archive_compat=True so we return _old_archive_ids for URLs without an asset_id
285+ return self ._extract_asset (asset_data , episode_id , episode_info , archive_compat = True )
286+
161287 _RX_OBFUSCATION = re .compile (r'''(?xs)
162288 atob\s*\(\s*decodeURIComponent\s*\(\s*
163289 (\[[0-9A-Za-z%,'"]*\])
@@ -172,25 +298,35 @@ def __unobfuscate(self, data):
172298 )).decode ('iso-8859-1' )),
173299 data )
174300
175- def _extract_from_html (self , url , episode_id ):
176- webpage = self ._download_webpage (url , episode_id )
301+ def _extract_from_html (self , url , program_id , episode_id , asset_id ):
302+ webpage = self ._download_webpage (url , asset_id or episode_id )
303+ if not asset_id :
304+ asset_id = self ._search_regex (r'\basset_id\s*:\s*"(\d+)"' , webpage , 'asset ID' )
305+ old_archive_ids = [make_archive_id (self , episode_id )]
306+ else :
307+ old_archive_ids = None
177308
178309 formats = []
179310 subtitles = {}
180311 media_urls = traverse_obj (re .findall (r'(?:var\s+f\s*=|RTPPlayer\({[^}]+file:)\s*({[^}]+}|"[^"]+")' , webpage ), (
181312 - 1 , (({self .__unobfuscate }, {js_to_json }, {json .loads }, {dict .values }, ...), {json .loads })))
182- formats , subtitles = self ._extract_formats (media_urls , episode_id )
313+ formats , subtitles = self ._extract_formats (media_urls , asset_id )
183314
184315 return {
185- 'id' : episode_id ,
316+ 'id' : asset_id ,
317+ 'episode_id' : episode_id ,
318+ 'series_id' : program_id ,
186319 'formats' : formats ,
187320 'subtitles' : subtitles ,
188321 'description' : self ._html_search_meta (['og:description' , 'twitter:description' ], webpage , default = None ),
189322 'thumbnail' : self ._html_search_meta (['og:image' , 'twitter:image' ], webpage , default = None ),
190- ** self ._search_json_ld (webpage , episode_id , default = {}),
323+ ** self ._search_json_ld (webpage , asset_id , default = {}),
191324 'title' : self ._html_search_meta (['og:title' , 'twitter:title' ], webpage , default = None ),
325+ '_old_archive_ids' : old_archive_ids ,
192326 }
193327
194328 def _real_extract (self , url ):
195- program_id , episode_id = self ._match_valid_url (url ).group ('program_id' , 'id' )
196- return self ._extract_from_api (program_id , episode_id ) or self ._extract_from_html (url , episode_id )
329+ program_id , episode_id , asset_id = self ._match_valid_url (url ).group ('program_id' , 'episode_id' , 'asset_id' )
330+ return (
331+ self ._extract_from_api (program_id , episode_id , asset_id )
332+ or self ._extract_from_html (url , program_id , episode_id , asset_id ))
0 commit comments