]>
Commit | Line | Data |
---|---|---|
1 | import functools | |
2 | ||
3 | from .common import InfoExtractor | |
4 | from ..utils import ( | |
5 | ExtractorError, | |
6 | OnDemandPagedList, | |
7 | UserNotLive, | |
8 | filter_dict, | |
9 | int_or_none, | |
10 | parse_iso8601, | |
11 | str_or_none, | |
12 | url_or_none, | |
13 | ) | |
14 | from ..utils.traversal import traverse_obj | |
15 | ||
16 | ||
17 | class NuumBaseIE(InfoExtractor): | |
18 | def _call_api(self, path, video_id, description, query={}): | |
19 | response = self._download_json( | |
20 | f'https://nuum.ru/api/v2/{path}', video_id, query=query, | |
21 | note=f'Downloading {description} metadata', | |
22 | errnote=f'Unable to download {description} metadata') | |
23 | if error := response.get('error'): | |
24 | raise ExtractorError(f'API returned error: {error!r}') | |
25 | return response['result'] | |
26 | ||
27 | def _get_channel_info(self, channel_name): | |
28 | return self._call_api( | |
29 | 'broadcasts/public', video_id=channel_name, description='channel', | |
30 | query={ | |
31 | 'with_extra': 'true', | |
32 | 'channel_name': channel_name, | |
33 | 'with_deleted': 'true', | |
34 | }) | |
35 | ||
36 | def _parse_video_data(self, container, extract_formats=True): | |
37 | stream = traverse_obj(container, ('media_container_streams', 0, {dict})) or {} | |
38 | media = traverse_obj(stream, ('stream_media', 0, {dict})) or {} | |
39 | media_url = traverse_obj(media, ( | |
40 | 'media_meta', ('media_archive_url', 'media_url'), {url_or_none}), get_all=False) | |
41 | ||
42 | video_id = str(container['media_container_id']) | |
43 | is_live = media.get('media_status') == 'RUNNING' | |
44 | ||
45 | formats, subtitles = None, None | |
46 | if extract_formats: | |
47 | formats, subtitles = self._extract_m3u8_formats_and_subtitles( | |
48 | media_url, video_id, 'mp4', live=is_live) | |
49 | ||
50 | return filter_dict({ | |
51 | 'id': video_id, | |
52 | 'is_live': is_live, | |
53 | 'formats': formats, | |
54 | 'subtitles': subtitles, | |
55 | **traverse_obj(container, { | |
56 | 'title': ('media_container_name', {str}), | |
57 | 'description': ('media_container_description', {str}), | |
58 | 'timestamp': ('created_at', {parse_iso8601}), | |
59 | 'channel': ('media_container_channel', 'channel_name', {str}), | |
60 | 'channel_id': ('media_container_channel', 'channel_id', {str_or_none}), | |
61 | }), | |
62 | **traverse_obj(stream, { | |
63 | 'view_count': ('stream_total_viewers', {int_or_none}), | |
64 | 'concurrent_view_count': ('stream_current_viewers', {int_or_none}), | |
65 | }), | |
66 | **traverse_obj(media, { | |
67 | 'duration': ('media_duration', {int_or_none}), | |
68 | 'thumbnail': ('media_meta', ('media_preview_archive_url', 'media_preview_url'), {url_or_none}), | |
69 | }, get_all=False), | |
70 | }) | |
71 | ||
72 | ||
73 | class NuumMediaIE(NuumBaseIE): | |
74 | IE_NAME = 'nuum:media' | |
75 | _VALID_URL = r'https?://nuum\.ru/(?:streams|videos|clips)/(?P<id>[\d]+)' | |
76 | _TESTS = [{ | |
77 | 'url': 'https://nuum.ru/streams/1592713-7-days-to-die', | |
78 | 'only_matching': True, | |
79 | }, { | |
80 | 'url': 'https://nuum.ru/videos/1567547-toxi-hurtz', | |
81 | 'md5': 'f1d9118a30403e32b702a204eb03aca3', | |
82 | 'info_dict': { | |
83 | 'id': '1567547', | |
84 | 'ext': 'mp4', | |
85 | 'title': 'Toxi$ - Hurtz', | |
86 | 'description': '', | |
87 | 'timestamp': 1702631651, | |
88 | 'upload_date': '20231215', | |
89 | 'thumbnail': r're:^https?://.+\.jpg', | |
90 | 'view_count': int, | |
91 | 'concurrent_view_count': int, | |
92 | 'channel_id': '6911', | |
93 | 'channel': 'toxis', | |
94 | 'duration': 116, | |
95 | }, | |
96 | }, { | |
97 | 'url': 'https://nuum.ru/clips/1552564-pro-misu', | |
98 | 'md5': 'b248ae1565b1e55433188f11beeb0ca1', | |
99 | 'info_dict': { | |
100 | 'id': '1552564', | |
101 | 'ext': 'mp4', | |
102 | 'title': 'Про Мису 🙃', | |
103 | 'timestamp': 1701971828, | |
104 | 'upload_date': '20231207', | |
105 | 'thumbnail': r're:^https?://.+\.jpg', | |
106 | 'view_count': int, | |
107 | 'concurrent_view_count': int, | |
108 | 'channel_id': '3320', | |
109 | 'channel': 'Misalelik', | |
110 | 'duration': 41, | |
111 | }, | |
112 | }] | |
113 | ||
114 | def _real_extract(self, url): | |
115 | video_id = self._match_id(url) | |
116 | video_data = self._call_api(f'media-containers/{video_id}', video_id, 'media') | |
117 | ||
118 | return self._parse_video_data(video_data) | |
119 | ||
120 | ||
121 | class NuumLiveIE(NuumBaseIE): | |
122 | IE_NAME = 'nuum:live' | |
123 | _VALID_URL = r'https?://nuum\.ru/channel/(?P<id>[^/#?]+)/?(?:$|[#?])' | |
124 | _TESTS = [{ | |
125 | 'url': 'https://nuum.ru/channel/mts_live', | |
126 | 'only_matching': True, | |
127 | }] | |
128 | ||
129 | def _real_extract(self, url): | |
130 | channel = self._match_id(url) | |
131 | channel_info = self._get_channel_info(channel) | |
132 | if traverse_obj(channel_info, ('channel', 'channel_is_live')) is False: | |
133 | raise UserNotLive(video_id=channel) | |
134 | ||
135 | info = self._parse_video_data(channel_info['media_container']) | |
136 | return { | |
137 | 'webpage_url': f'https://nuum.ru/streams/{info["id"]}', | |
138 | 'extractor_key': NuumMediaIE.ie_key(), | |
139 | 'extractor': NuumMediaIE.IE_NAME, | |
140 | **info, | |
141 | } | |
142 | ||
143 | ||
144 | class NuumTabIE(NuumBaseIE): | |
145 | IE_NAME = 'nuum:tab' | |
146 | _VALID_URL = r'https?://nuum\.ru/channel/(?P<id>[^/#?]+)/(?P<type>streams|videos|clips)' | |
147 | _TESTS = [{ | |
148 | 'url': 'https://nuum.ru/channel/dankon_/clips', | |
149 | 'info_dict': { | |
150 | 'id': 'dankon__clips', | |
151 | 'title': 'Dankon_', | |
152 | }, | |
153 | 'playlist_mincount': 29, | |
154 | }, { | |
155 | 'url': 'https://nuum.ru/channel/dankon_/videos', | |
156 | 'info_dict': { | |
157 | 'id': 'dankon__videos', | |
158 | 'title': 'Dankon_', | |
159 | }, | |
160 | 'playlist_mincount': 2, | |
161 | }, { | |
162 | 'url': 'https://nuum.ru/channel/dankon_/streams', | |
163 | 'info_dict': { | |
164 | 'id': 'dankon__streams', | |
165 | 'title': 'Dankon_', | |
166 | }, | |
167 | 'playlist_mincount': 1, | |
168 | }] | |
169 | ||
170 | _PAGE_SIZE = 50 | |
171 | ||
172 | def _fetch_page(self, channel_id, tab_type, tab_id, page): | |
173 | CONTAINER_TYPES = { | |
174 | 'clips': ['SHORT_VIDEO', 'REVIEW_VIDEO'], | |
175 | 'videos': ['LONG_VIDEO'], | |
176 | 'streams': ['SINGLE'], | |
177 | } | |
178 | ||
179 | media_containers = self._call_api( | |
180 | 'media-containers', video_id=tab_id, description=f'{tab_type} tab page {page + 1}', | |
181 | query={ | |
182 | 'limit': self._PAGE_SIZE, | |
183 | 'offset': page * self._PAGE_SIZE, | |
184 | 'channel_id': channel_id, | |
185 | 'media_container_status': 'STOPPED', | |
186 | 'media_container_type': CONTAINER_TYPES[tab_type], | |
187 | }) | |
188 | for container in traverse_obj(media_containers, (..., {dict})): | |
189 | metadata = self._parse_video_data(container, extract_formats=False) | |
190 | yield self.url_result(f'https://nuum.ru/videos/{metadata["id"]}', NuumMediaIE, **metadata) | |
191 | ||
192 | def _real_extract(self, url): | |
193 | channel_name, tab_type = self._match_valid_url(url).group('id', 'type') | |
194 | tab_id = f'{channel_name}_{tab_type}' | |
195 | channel_data = self._get_channel_info(channel_name)['channel'] | |
196 | ||
197 | return self.playlist_result(OnDemandPagedList(functools.partial( | |
198 | self._fetch_page, channel_data['channel_id'], tab_type, tab_id), self._PAGE_SIZE), | |
199 | playlist_id=tab_id, playlist_title=channel_data.get('channel_name')) |