diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 5208639e3a..399c8429a9 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -287,6 +287,7 @@ from .boomplay import (
BoomPlayPlaylistIE,
BoomPlayPodcastIE,
BoomPlaySearchIE,
+ BoomPlaySearchPageIE,
BoomPlayVideoIE,
)
from .boosty import BoostyIE
diff --git a/yt_dlp/extractor/boomplay.py b/yt_dlp/extractor/boomplay.py
index 692f4d98b4..3fa6030c85 100644
--- a/yt_dlp/extractor/boomplay.py
+++ b/yt_dlp/extractor/boomplay.py
@@ -8,11 +8,10 @@ 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_element_by_attribute,
- get_element_by_class,
- get_elements_by_attribute,
+ get_elements_text_and_html_by_attribute,
int_or_none,
join_nonempty,
merge_dicts,
@@ -30,12 +29,40 @@ from ..utils.traversal import traverse_obj
class BoomPlayBaseIE(InfoExtractor):
- # Calculated from const values, see lhx.AESUtils.encrypt, see public.js
+ # 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):
@@ -55,10 +82,15 @@ class BoomPlayBaseIE(InfoExtractor):
}), headers={
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
})
- if not (source := resp.get('source')) and resp.get('code'):
- raise ExtractorError(resp.get('desc') or 'Please solve the captcha')
- return unpad_pkcs7(
- aes_cbc_decrypt_bytes(base64.b64decode(source), self._KEY, self._IV)).decode()
+ 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)):
@@ -75,38 +107,35 @@ class BoomPlayBaseIE(InfoExtractor):
else:
self.raise_no_formats('No formats found')
- def _extract_page_metadata(self, webpage, _id):
- metadata_div = get_element_by_attribute(
- 'class', r'[^\'"]*(?<=[\'"\s])summary(?=[\'"\s])[^\'"]*', webpage,
- tag='div', escape_value=False) or ''
- metadata_entries = re.findall(r'(?s)(?P.*?)', metadata_div) or []
- description = get_element_by_attribute(
- 'class', r'[^\'"]*(?<=[\'"\s])description_content(?=[\'"\s])[^\'"]*', webpage,
- tag='span', escape_value=False) or 'Listen and download music for free on Boomplay!'
+ 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 = get_element_by_attribute(
- 'class', r'[^\'"]*(?<=[\'"\s])songDetailInfo(?=[\'"\s])[^\'"]*', webpage,
- tag='section', escape_value=False) or ''
- metadata_entries.extend(re.findall(r'(?s)
(?P.*?)', details_section) or [])
+ 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'([^<]+)
', metadata_div, 'title', default=None),
+ '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(get_element_by_class('btn_favorite', metadata_div)),
- 'repost_count': parse_count(get_element_by_class('btn_share', metadata_div)),
- 'comment_count': parse_count(get_element_by_class('btn_comment', metadata_div)),
- 'duration': parse_duration(get_element_by_class('btn_duration', metadata_div)),
- 'upload_date': unified_strdate(strip_or_none(get_element_by_class('btn_pubDate', metadata_div))),
+ '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(':', 2)
+ k, v = clean_html(metadata_entry).split(':', 1)
v = v.strip()
if 'artist' in k.lower():
page_metadata['artists'] = [v]
@@ -118,8 +147,8 @@ class BoomPlayBaseIE(InfoExtractor):
page_metadata['release_year'] = int_or_none(v)
return page_metadata
- def _extract_suitable_links(self, webpage, media_types):
- if not media_types:
+ 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))
@@ -132,35 +161,21 @@ class BoomPlayBaseIE(InfoExtractor):
(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?
(?<=\s)href\s*=\s*(?P<_q>['"])
(?:
- (?!javascript:)(?P/(?:{media_types})/\d+?)
+ (?!javascript:)(?P/(?:{media_types})/\d+/?[\-a-zA-Z=?:;@]*)
)
(?P=_q)
(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?
- ''', webpage), (..., 'link', {self._urljoin}, {self.url_result})))
+ >''', webpage), (..., 'link', {self._urljoin}, {self.url_result})))
def _extract_playlist_entries(self, webpage, media_types, warn=True):
song_list = strip_or_none(
- get_element_by_attribute(
- 'class', r'[^\'"]*(?<=[\'"\s])morePart_musics(?=[\'"\s])[^\'"]*', webpage,
- tag='ol', escape_value=False)
- or get_element_by_attribute(
- 'class', r'[^\'"]*(?<=[\'"\s])morePart(?=[\'"\s])[^\'"]*', webpage,
- tag='ol', escape_value=False)
+ 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(re.finditer(
- r'''(?x)
- "']|"[^"]*"|'[^']*')*)?
- (?<=\s)class\s*=\s*(?P<_q>['"])
- (?:
- [^\'"]*(?<=[\'"\s])songName(?=[\'"\s])[^\'"]*
- )
- (?P=_q)
- (?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?
- >
- ''', song_list),
- (..., 0, {extract_attributes}, 'href', {self._urljoin}, {self.url_result}))
+ 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!')
@@ -195,7 +210,8 @@ class BoomPlayMusicIE(BoomPlayBaseIE):
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, {
@@ -286,14 +302,17 @@ class BoomPlayPodcastIE(BoomPlayBaseIE):
def _real_extract(self, url):
_id = self._match_id(url)
webpage = self._download_webpage(url, _id)
- song_list = get_elements_by_attribute(
- 'class', r'[^\'"]*(?<=[\'"\s])morePart_musics(?=[\'"\s])[^\'"]*', webpage,
- tag='ol', escape_value=False)[0]
+ song_list = self._get_element_by_class_and_tag('morePart_musics', 'ol', webpage)
song_list = traverse_obj(re.finditer(
r'''(?x)
- <(?Pli)
- (?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?
- \sdata-id\s*=\s*(?P<_q>['"]?)(?:(?P\d+))(?P=_q)''',
+ "']|"[^"]*"|'[^']*')*)?
+ \sdata-id\s*=\s*
+ (?P<_q>['"]?)
+ (?P\d+)
+ (?P=_q)
+ (?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?
+ >''',
song_list),
(..., 'id', {
lambda x: self.url_result(
@@ -350,7 +369,47 @@ class BoomPlayPlaylistIE(BoomPlayBaseIE):
class BoomPlayGenericPlaylistIE(BoomPlayBaseIE):
_VALID_URL = r'https?://(?:www\.)?boomplay\.com/.+'
_TESTS = [{
- 'url': 'https://www.boomplay.com/search/default/Rise%20of%20the%20Fallen%20Heroes',
+ '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,
+ BoomPlaySearchPageIE,
+ 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 BoomPlaySearchPageIE(BoomPlayBaseIE):
+ _TESTS = [{
+ 'url': 'https://www.boomplay.com/search/default/%20Rise%20of%20the%20Falletesn%20Heroes%20fatbunny',
'md5': 'c5fb4f23e6aae98064230ef3c39c2178',
'info_dict': {
'id': '165481965',
@@ -381,29 +440,21 @@ class BoomPlayGenericPlaylistIE(BoomPlayBaseIE):
'upload_date': '20241010',
'duration': 177.0,
},
- 'expected_warnings': ['Failed to extract playlist entries, finding suitable links instead!'],
'params': {'playlist_items': '1'},
}]
- @classmethod
- def suitable(cls, url):
- if not any(ie.suitable(url) for ie in (
- BoomPlayEpisodeIE,
- BoomPlayMusicIE,
- BoomPlayPlaylistIE,
- BoomPlayPodcastIE,
- BoomPlayVideoIE,
- )):
- return super().suitable(url)
- return False
+ @classproperty
+ def _VALID_URL(cls):
+ return rf'https?://(?:www\.)?boomplay\.com/search/(?P{"|".join(cls._MEDIA_TYPES)})/(?P[^?/]+)'
def _real_extract(self, url):
- _id = self._generic_id(url)
- webpage = self._download_webpage(url, _id)
- # TODO: pass media types based on search types
+ 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, self._MEDIA_TYPES),
- **self._extract_page_metadata(webpage, _id))
+ self._extract_playlist_entries(webpage, media_type, warn=media_type == 'songs'),
+ **self._extract_page_metadata(webpage, query))
class BoomPlaySearchIE(SearchInfoExtractor):
@@ -416,4 +467,5 @@ class BoomPlaySearchIE(SearchInfoExtractor):
def _search_results(self, query):
yield self.url_result(
- f'https://www.boomplay.com/search/default/{urllib.parse.quote(query)}')
+ f'https://www.boomplay.com/search/default/{urllib.parse.quote(query)}',
+ BoomPlaySearchPageIE)