import base64 import functools import json import re import urllib.parse from .common import InfoExtractor, SearchInfoExtractor from ..aes import aes_cbc_decrypt_bytes, aes_cbc_encrypt_bytes, unpad_pkcs7 from ..utils import ( ExtractorError, classproperty, clean_html, extract_attributes, get_elements_text_and_html_by_attribute, int_or_none, join_nonempty, merge_dicts, orderedSet, parse_count, parse_duration, strip_or_none, unified_strdate, url_or_none, urlencode_postdata, urljoin, variadic, ) from ..utils.traversal import traverse_obj class BoomplayBaseIE(InfoExtractor): # Calculated from const values, see lhx.AESUtils.encrypt in public.js # Note that the real key/iv differs from `lhx.AESUtils.key`/`lhx.AESUtils.iv` _KEY = b'boomplayVr3xopAM' _IV = b'boomplay8xIsKTn9' _BASE = 'https://www.boomplay.com' _MEDIA_TYPES = ('songs', 'video', 'episode', 'podcasts', 'playlists', 'artists', 'albums') _GEO_COUNTRIES = ['NG'] @staticmethod def __yield_elements_text_and_html_by_class_and_tag(class_, tag, html): """ Yields content of all element matching `tag.class_` in html class_ must be re escaped """ # get_elements_text_and_html_by_attribute returns a generator return get_elements_text_and_html_by_attribute( 'class', rf'''[^'"]*(?<=['"\s]){class_}(?=['"\s])[^'"]*''', html, tag=tag, escape_value=False) @classmethod def __yield_elements_by_class_and_tag(cls, *args, **kwargs): return (content for content, _ in cls.__yield_elements_text_and_html_by_class_and_tag(*args, **kwargs)) @classmethod def __yield_elements_html_by_class_and_tag(cls, *args, **kwargs): return (whole for _, whole in cls.__yield_elements_text_and_html_by_class_and_tag(*args, **kwargs)) @classmethod def _get_elements_by_class_and_tag(cls, class_, tag, html): return list(cls.__yield_elements_by_class_and_tag(class_, tag, html)) @classmethod def _get_element_by_class_and_tag(cls, class_, tag, html): return next(cls.__yield_elements_by_class_and_tag(class_, tag, html), None) @classmethod def _urljoin(cls, path): if not hasattr(path, 'startswith') or path.startswith('javascript:'): return None return url_or_none(urljoin(base=cls._BASE, path=path)) def _get_playurl(self, item_id, item_type): resp = self._download_json( 'https://www.boomplay.com/getResourceAddr', item_id, note='Downloading play URL', errnote='Failed to download play URL', data=urlencode_postdata({ 'param': base64.b64encode(aes_cbc_encrypt_bytes(json.dumps({ 'itemID': item_id, 'itemType': item_type, }).encode(), self._KEY, self._IV)).decode(), }), headers={ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', }) if not (source := resp.get('source')) and (code := resp.get('code')): if 'unavailable in your country' in (desc := resp.get('desc')) or '': # since NG must have failed ... self.raise_geo_restricted(countries=['GH', 'KE', 'TZ', 'CM', 'CI']) else: raise ExtractorError(desc or f'Failed to get play url, code: {code}') return unpad_pkcs7(aes_cbc_decrypt_bytes( base64.b64decode(source), self._KEY, self._IV)).decode() def _extract_formats(self, _id, item_type='MUSIC', **kwargs): if url := url_or_none(self._get_playurl(_id, item_type)): return [{ 'format_id': '0', 'url': url, 'http_headers': { 'Origin': 'https://www.boomplay.com', 'Referer': 'https://www.boomplay.com', 'X-Boomplay-Ref': 'Boomplay_WEBV1', }, **kwargs, }] else: self.raise_no_formats('No formats found') def _extract_page_metadata(self, webpage, _id, playlist=False): metadata_div = self._get_element_by_class_and_tag('summary', 'div', webpage) or '' metadata_entries = re.findall(r'(?si)(?P.*?)', metadata_div) or [] description = ( self._get_element_by_class_and_tag('description_content', 'span', webpage) or 'Listen and download music for free on Boomplay!') description = clean_html(description.strip()) if description == 'Listen and download music for free on Boomplay!': description = None details_section = self._get_element_by_class_and_tag('songDetailInfo', 'section', webpage) or '' metadata_entries.extend(re.findall(r'(?si)
  • (?P.*?)
  • ', details_section) or []) page_metadata = { 'id': _id, 'title': self._html_search_regex(r']*>([^<]+)', webpage, 'title', default=None), 'thumbnail': self._html_search_meta(['og:image', 'twitter:image'], webpage, 'thumbnail', default=''), 'like_count': parse_count(self._get_element_by_class_and_tag('btn_favorite', 'button', metadata_div)), 'repost_count': parse_count(self._get_element_by_class_and_tag('btn_share', 'button', metadata_div)), 'comment_count': parse_count(self._get_element_by_class_and_tag('btn_comment', 'button', metadata_div)), 'duration': parse_duration(self._get_element_by_class_and_tag('btn_duration', 'button', metadata_div)), 'upload_date': unified_strdate(strip_or_none( self._get_element_by_class_and_tag('btn_pubDate', 'button', metadata_div))), 'description': description, } for metadata_entry in metadata_entries: if ':' not in metadata_entry: continue k, v = clean_html(metadata_entry).split(':', 1) v = v.strip() if 'artist' in k.lower(): page_metadata['artists'] = [v] elif 'album' in k.lower(): page_metadata['album'] = v elif 'genre' in k.lower(): page_metadata['genres'] = [v] elif 'year of release' in k.lower(): page_metadata['release_year'] = int_or_none(v) return page_metadata def _extract_suitable_links(self, webpage, media_types=None): if media_types is None: media_types = self._MEDIA_TYPES media_types = list(variadic(media_types)) for idx, v in enumerate(media_types): media_types[idx] = re.escape(v) if v in self._MEDIA_TYPES else '' media_types = join_nonempty(*media_types, delim='|') return orderedSet(traverse_obj(re.finditer( rf'''(?x) "']|"[^"]*"|'[^']*')*)? (?<=\s)href\s*=\s*(?P<_q>['"]) (?: (?!javascript:)(?P/(?:{media_types})/\d+/?[\-a-zA-Z=?&#:;@]*) ) (?P=_q) (?:\s(?:[^>"']|"[^"]*"|'[^']*')*)? >''', webpage), (..., 'link', {self._urljoin}, {self.url_result}))) def _extract_playlist_entries(self, webpage, media_types, warn=True): song_list = strip_or_none( self._get_element_by_class_and_tag('morePart_musics', 'ol', webpage) or self._get_element_by_class_and_tag('morePart', 'ol', webpage) or '') entries = traverse_obj(self.__yield_elements_html_by_class_and_tag( 'songName', 'a', song_list), (..., {extract_attributes}, 'href', {self._urljoin}, {self.url_result})) if not entries: if warn: self.report_warning('Failed to extract playlist entries, finding suitable links instead!') return self._extract_suitable_links(webpage, media_types) return entries class BoomplayMusicIE(BoomplayBaseIE): _VALID_URL = r'https?://(?:www\.)?boomplay\.com/songs/(?P\d+)' _TEST = { 'url': 'https://www.boomplay.com/songs/165481965', 'md5': 'c5fb4f23e6aae98064230ef3c39c2178', 'info_dict': { 'title': 'Rise of the Fallen Heroes', 'ext': 'mp3', 'id': '165481965', 'artists': ['fatbunny'], 'thumbnail': 'https://source.boomplaymusic.com/group10/M00/04/29/375ecda38f6f48179a93c72ab909118f_464_464.jpg', 'channel_url': 'https://www.boomplay.com/artists/52723101', 'duration': 125.0, 'release_year': 2024, 'comment_count': int, 'like_count': int, 'repost_count': int, 'album': 'Legendary Battle', 'genres': ['Metal'], }, } def _real_extract(self, url): song_id = self._match_id(url) webpage = self._download_webpage(url, song_id) ld_json_meta = next(self._yield_json_ld(webpage, song_id)) # TODO: extract comments(and lyrics? they don't have timestamps) # example: https://www.boomplay.com/songs/96352673?from=home return merge_dicts( self._extract_page_metadata(webpage, song_id), traverse_obj(ld_json_meta, { 'title': 'name', 'thumbnail': 'image', 'channel_url': ('byArtist', 0, '@id'), 'artists': ('byArtist', ..., 'name'), 'duration': ('duration', {parse_duration}), }), { 'formats': self._extract_formats(song_id, 'MUSIC', vcodec='none'), }) class BoomplayVideoIE(BoomplayBaseIE): _VALID_URL = r'https?://(?:www\.)?boomplay\.com/video/(?P\d+)' _TEST = { 'url': 'https://www.boomplay.com/video/1154892', 'md5': 'd9b67ad333d2292a82922062d065352d', 'info_dict': { 'id': '1154892', 'ext': 'mp4', 'title': 'Autumn blues', 'thumbnail': 'https://source.boomplaymusic.com/group10/M00/10/10/2171dee9e1f8452e84021560729edb88.jpg', 'upload_date': '20241010', 'timestamp': 1728599214, 'view_count': int, 'duration': 177.0, 'description': 'Autumn blues by Lugo', }, } def _real_extract(self, url): video_id = self._match_id(url) webpage = self._download_webpage(url, video_id) return merge_dicts( self._extract_page_metadata(webpage, video_id), self._search_json_ld(webpage, video_id), { 'formats': self._extract_formats(video_id, 'VIDEO', ext='mp4'), }) class BoomplayEpisodeIE(BoomplayBaseIE): _VALID_URL = r'https?://(?:www\.)?boomplay\.com/episode/(?P\d+)' _TEST = { 'url': 'https://www.boomplay.com/episode/7132706', 'md5': 'f26e236b764baa53d7a2cbb7e9ce6dc4', 'info_dict': { 'id': '7132706', 'ext': 'mp3', 'title': 'Letting Go', 'repost_count': int, 'thumbnail': 'https://source.boomplaymusic.com/group10/M00/05/06/fc535eaa25714b43a47185a9831887a5_320_320.jpg', 'comment_count': int, 'duration': 921.0, 'upload_date': '20240506', 'description': 'md5:5ec684b281fa0f9e4c31b3ee20c5e57a', }, } def _real_extract(self, url): ep_id = self._match_id(url) webpage = self._download_webpage(url, ep_id) return merge_dicts( self._extract_page_metadata(webpage, ep_id), { 'title': self._og_search_title(webpage, fatal=True).rsplit('|', 2)[0].strip(), 'description': self._html_search_meta( ['description', 'og:description', 'twitter:description'], webpage), 'formats': self._extract_formats(ep_id, 'EPISODE', vcodec='none'), }) class BoomplayPodcastIE(BoomplayBaseIE): _VALID_URL = r'https?://(?:www\.)?boomplay\.com/podcasts/(?P\d+)' _TEST = { 'url': 'https://www.boomplay.com/podcasts/5372', 'playlist_count': 200, 'info_dict': { 'id': '5372', 'title': 'TED Talks Daily', 'description': 'md5:541182e787ce8fd578c835534c907077', 'thumbnail': 'https://source.boomplaymusic.com/group10/M00/12/22/6f9cf97ad6f846a0a7882c98dfcf4f8c_320_320.jpg', 'repost_count': int, 'comment_count': int, 'like_count': int, }, } def _real_extract(self, url): _id = self._match_id(url) webpage = self._download_webpage(url, _id) song_list = self._get_element_by_class_and_tag('morePart_musics', 'ol', webpage) song_list = traverse_obj(re.finditer( r'''(?x)
  • "']|"[^"]*"|'[^']*')*)? \sdata-id\s*=\s* (?P<_q>['"]?) (?P\d+) (?P=_q) (?:\s(?:[^>"']|"[^"]*"|'[^']*')*)? >''', song_list), (..., 'id', { lambda x: self.url_result( f'https://www.boomplay.com/episode/{x}', BoomplayEpisodeIE, x), })) return self.playlist_result( song_list, _id, playlist_title=self._og_search_title(webpage, fatal=True).rsplit('|', 2)[0].strip(), playlist_description=self._og_search_description(webpage, default=''), **self._extract_page_metadata(webpage, _id)) class BoomplayPlaylistIE(BoomplayBaseIE): _VALID_URL = r'https?://(?:www\.)?boomplay\.com/(?:playlists|artists|albums)/(?P\d+)' _TESTS = [{ 'url': 'https://www.boomplay.com/playlists/33792494', 'info_dict': { 'id': '33792494', 'title': 'Daily Trending Indonesia', 'thumbnail': 'https://source.boomplaymusic.com/group10/M00/08/19/d05d431ee616412caeacd7f78f4f68f5_320_320.jpeg', 'repost_count': int, 'comment_count': int, 'like_count': int, 'description': 'md5:7ebdffc5137c77acb62acb3c89248445', }, 'playlist_count': 10, }, { 'url': 'https://www.boomplay.com/artists/52723101', 'only_matching': True, }, { 'url': 'https://www.boomplay.com/albums/89611238?from=home#google_vignette', 'only_matching': True, }] def _real_extract(self, url): playlist_id = self._match_id(url) webpage = self._download_webpage(url, playlist_id) json_ld_metadata = next(self._yield_json_ld(webpage, playlist_id)) # schema `MusicGroup` not supported by self._json_ld() return self.playlist_result(**merge_dicts( self._extract_page_metadata(webpage, playlist_id), traverse_obj(json_ld_metadata, { 'entries': ('track', ..., 'url', { functools.partial(self.url_result, ie=BoomplayMusicIE), }), 'playlist_title': 'name', 'thumbnail': 'image', 'artists': ('byArtist', ..., 'name'), 'channel_url': ('byArtist', 0, '@id'), }))) class BoomplayGenericPlaylistIE(BoomplayBaseIE): _VALID_URL = r'https?://(?:www\.)?boomplay\.com/.+' _TESTS = [{ 'url': 'https://www.boomplay.com/new-songs', 'playlist_mincount': 20, 'info_dict': { 'id': 'new-songs', 'title': 'New Songs', 'thumbnail': 'http://www.boomplay.com/pc/img/og_default_v3.jpg', }, }, { 'url': 'https://www.boomplay.com/trending-songs', 'playlist_mincount': 20, 'info_dict': { 'id': 'trending-songs', 'title': 'Trending Songs', 'thumbnail': 'http://www.boomplay.com/pc/img/og_default_v3.jpg', }, }] @classmethod def suitable(cls, url): if super().suitable(url): return not any(ie.suitable(url) for ie in ( BoomplayEpisodeIE, BoomplayMusicIE, BoomplayPlaylistIE, BoomplayPodcastIE, BoomplaySearchURLIE, BoomplayVideoIE, )) return False def _real_extract(self, url): _id = self._generic_id(url) webpage = self._download_webpage(url, _id) return self.playlist_result( self._extract_playlist_entries(webpage, self._MEDIA_TYPES), **self._extract_page_metadata(webpage, _id)) class BoomplaySearchURLIE(BoomplayBaseIE): _TESTS = [{ 'url': 'https://www.boomplay.com/search/default/%20Rise%20of%20the%20Falletesn%20Heroes%20fatbunny', 'md5': 'c5fb4f23e6aae98064230ef3c39c2178', 'info_dict': { 'id': '165481965', 'ext': 'mp3', 'title': 'Rise of the Fallen Heroes', 'duration': 125.0, 'genres': ['Metal'], 'artists': ['fatbunny'], 'thumbnail': 'https://source.boomplaymusic.com/group10/M00/04/29/375ecda38f6f48179a93c72ab909118f_464_464.jpg', 'channel_url': 'https://www.boomplay.com/artists/52723101', 'comment_count': int, 'repost_count': int, 'album': 'Legendary Battle', 'release_year': 2024, 'like_count': int, }, }, { 'url': 'https://www.boomplay.com/search/video/%20Autumn%20blues', 'md5': 'd9b67ad333d2292a82922062d065352d', 'info_dict': { 'id': '1154892', 'title': 'Autumn blues', 'ext': 'mp4', 'timestamp': 1728599214, 'view_count': int, 'thumbnail': 'https://source.boomplaymusic.com/group10/M00/10/10/2171dee9e1f8452e84021560729edb88.jpg', 'description': 'Autumn blues by Lugo', 'upload_date': '20241010', 'duration': 177.0, }, 'params': {'playlist_items': '1'}, }] @classproperty def _VALID_URL(cls): return r'https?://(?:www\.)?boomplay\.com/search/(?Pdefault|video|episode|podcasts|playlists|artists|albums)/(?P[^?&#/]+)' def _real_extract(self, url): media_type, query = self._match_valid_url(url).group('media_type', 'query') if media_type == 'default': media_type = 'songs' webpage = self._download_webpage(url, query) return self.playlist_result( self._extract_playlist_entries(webpage, media_type, warn=media_type == 'songs'), **self._extract_page_metadata(webpage, query)) class BoomplaySearchIE(SearchInfoExtractor): _SEARCH_KEY = 'boomplaysearch' _RETURN_TYPE = 'url' _TEST = { 'url': 'boomplaysearch:rise of the fallen heroes', 'only_matching': True, } def _search_results(self, query): yield self.url_result( f'https://www.boomplay.com/search/default/{urllib.parse.quote(query)}', BoomplaySearchURLIE)