soundcloud.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. import functools
  2. import itertools
  3. import json
  4. import re
  5. from .common import InfoExtractor, SearchInfoExtractor
  6. from ..networking import HEADRequest
  7. from ..networking.exceptions import HTTPError
  8. from ..utils import (
  9. KNOWN_EXTENSIONS,
  10. ExtractorError,
  11. float_or_none,
  12. int_or_none,
  13. join_nonempty,
  14. mimetype2ext,
  15. parse_qs,
  16. str_or_none,
  17. try_call,
  18. unified_timestamp,
  19. update_url_query,
  20. url_or_none,
  21. urlhandle_detect_ext,
  22. )
  23. from ..utils.traversal import traverse_obj
  24. class SoundcloudEmbedIE(InfoExtractor):
  25. _VALID_URL = r'https?://(?:w|player|p)\.soundcloud\.com/player/?.*?\burl=(?P<id>.+)'
  26. _EMBED_REGEX = [r'<iframe[^>]+src=(["\'])(?P<url>(?:https?://)?(?:w\.)?soundcloud\.com/player.+?)\1']
  27. _TEST = {
  28. # from https://www.soundi.fi/uutiset/ennakkokuuntelussa-timo-kaukolammen-station-to-station-to-station-julkaisua-juhlitaan-tanaan-g-livelabissa/
  29. 'url': 'https://w.soundcloud.com/player/?visual=true&url=https%3A%2F%2Fapi.soundcloud.com%2Fplaylists%2F922213810&show_artwork=true&maxwidth=640&maxheight=960&dnt=1&secret_token=s-ziYey',
  30. 'only_matching': True,
  31. }
  32. def _real_extract(self, url):
  33. query = parse_qs(url)
  34. api_url = query['url'][0]
  35. secret_token = query.get('secret_token')
  36. if secret_token:
  37. api_url = update_url_query(api_url, {'secret_token': secret_token[0]})
  38. return self.url_result(api_url)
  39. class SoundcloudBaseIE(InfoExtractor):
  40. _NETRC_MACHINE = 'soundcloud'
  41. _API_V2_BASE = 'https://api-v2.soundcloud.com/'
  42. _BASE_URL = 'https://soundcloud.com/'
  43. _USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36'
  44. _API_AUTH_QUERY_TEMPLATE = '?client_id=%s'
  45. _API_AUTH_URL_PW = 'https://api-auth.soundcloud.com/web-auth/sign-in/password%s'
  46. _API_VERIFY_AUTH_TOKEN = 'https://api-auth.soundcloud.com/connect/session%s'
  47. _HEADERS = {}
  48. _IMAGE_REPL_RE = r'-([0-9a-z]+)\.jpg'
  49. _ARTWORK_MAP = {
  50. 'mini': 16,
  51. 'tiny': 20,
  52. 'small': 32,
  53. 'badge': 47,
  54. 't67x67': 67,
  55. 'large': 100,
  56. 't300x300': 300,
  57. 'crop': 400,
  58. 't500x500': 500,
  59. 'original': 0,
  60. }
  61. _DEFAULT_FORMATS = ['http_aac', 'hls_aac', 'http_opus', 'hls_opus', 'http_mp3', 'hls_mp3']
  62. @functools.cached_property
  63. def _is_requested(self):
  64. return re.compile(r'|'.join(set(
  65. re.escape(pattern).replace(r'\*', r'.*') if pattern != 'default'
  66. else '|'.join(map(re.escape, self._DEFAULT_FORMATS))
  67. for pattern in self._configuration_arg('formats', ['default'], ie_key=SoundcloudIE)
  68. ))).fullmatch
  69. def _store_client_id(self, client_id):
  70. self.cache.store('soundcloud', 'client_id', client_id)
  71. def _update_client_id(self):
  72. webpage = self._download_webpage('https://soundcloud.com/', None)
  73. for src in reversed(re.findall(r'<script[^>]+src="([^"]+)"', webpage)):
  74. script = self._download_webpage(src, None, fatal=False)
  75. if script:
  76. client_id = self._search_regex(
  77. r'client_id\s*:\s*"([0-9a-zA-Z]{32})"',
  78. script, 'client id', default=None)
  79. if client_id:
  80. self._CLIENT_ID = client_id
  81. self._store_client_id(client_id)
  82. return
  83. raise ExtractorError('Unable to extract client id')
  84. def _call_api(self, *args, **kwargs):
  85. non_fatal = kwargs.get('fatal') is False
  86. if non_fatal:
  87. del kwargs['fatal']
  88. query = kwargs.get('query', {}).copy()
  89. for _ in range(2):
  90. query['client_id'] = self._CLIENT_ID
  91. kwargs['query'] = query
  92. try:
  93. return self._download_json(*args, **kwargs)
  94. except ExtractorError as e:
  95. if isinstance(e.cause, HTTPError) and e.cause.status in (401, 403):
  96. self._store_client_id(None)
  97. self._update_client_id()
  98. continue
  99. elif non_fatal:
  100. self.report_warning(str(e))
  101. return False
  102. raise
  103. def _initialize_pre_login(self):
  104. self._CLIENT_ID = self.cache.load('soundcloud', 'client_id') or 'a3e059563d7fd3372b49b37f00a00bcf'
  105. def _verify_oauth_token(self, token):
  106. if self._request_webpage(
  107. self._API_VERIFY_AUTH_TOKEN % (self._API_AUTH_QUERY_TEMPLATE % self._CLIENT_ID),
  108. None, note='Verifying login token...', fatal=False,
  109. data=json.dumps({'session': {'access_token': token}}).encode()):
  110. self._HEADERS['Authorization'] = f'OAuth {token}'
  111. self.report_login()
  112. else:
  113. self.report_warning('Provided authorization token is invalid. Continuing as guest')
  114. def _real_initialize(self):
  115. if self._HEADERS:
  116. return
  117. if token := try_call(lambda: self._get_cookies(self._BASE_URL)['oauth_token'].value):
  118. self._verify_oauth_token(token)
  119. def _perform_login(self, username, password):
  120. if username != 'oauth':
  121. raise ExtractorError(
  122. 'Login using username and password is not currently supported. '
  123. 'Use "--username oauth --password <oauth_token>" to login using an oauth token, '
  124. f'or else {self._login_hint(method="cookies")}', expected=True)
  125. if self._HEADERS:
  126. return
  127. self._verify_oauth_token(password)
  128. r'''
  129. def genDevId():
  130. def genNumBlock():
  131. return ''.join([str(random.randrange(10)) for i in range(6)])
  132. return '-'.join([genNumBlock() for i in range(4)])
  133. payload = {
  134. 'client_id': self._CLIENT_ID,
  135. 'recaptcha_pubkey': 'null',
  136. 'recaptcha_response': 'null',
  137. 'credentials': {
  138. 'identifier': username,
  139. 'password': password
  140. },
  141. 'signature': self.sign(username, password, self._CLIENT_ID),
  142. 'device_id': genDevId(),
  143. 'user_agent': self._USER_AGENT
  144. }
  145. response = self._call_api(
  146. self._API_AUTH_URL_PW % (self._API_AUTH_QUERY_TEMPLATE % self._CLIENT_ID),
  147. None, note='Verifying login token...', fatal=False,
  148. data=json.dumps(payload).encode())
  149. if token := traverse_obj(response, ('session', 'access_token', {str})):
  150. self._HEADERS['Authorization'] = f'OAuth {token}'
  151. self.report_login()
  152. return
  153. raise ExtractorError('Unable to get access token, login may have failed', expected=True)
  154. '''
  155. # signature generation
  156. def sign(self, user, pw, clid):
  157. a = 33
  158. i = 1
  159. s = 440123
  160. w = 117
  161. u = 1800000
  162. l = 1042
  163. b = 37
  164. k = 37
  165. c = 5
  166. n = '0763ed7314c69015fd4a0dc16bbf4b90' # _KEY
  167. y = '8' # _REV
  168. r = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36' # _USER_AGENT
  169. e = user # _USERNAME
  170. t = clid # _CLIENT_ID
  171. d = '-'.join([str(mInt) for mInt in [a, i, s, w, u, l, b, k]])
  172. h = n + y + d + r + e + t + d + n
  173. m = 8011470
  174. for f in range(len(h)):
  175. m = (m >> 1) + ((1 & m) << 23)
  176. m += ord(h[f])
  177. m &= 16777215
  178. # c is not even needed
  179. return f'{y}:{d}:{m:x}:{c}'
  180. def _extract_info_dict(self, info, full_title=None, secret_token=None, extract_flat=False):
  181. track_id = str(info['id'])
  182. title = info['title']
  183. format_urls = set()
  184. formats = []
  185. query = {'client_id': self._CLIENT_ID}
  186. if secret_token:
  187. query['secret_token'] = secret_token
  188. if not extract_flat and info.get('downloadable') and info.get('has_downloads_left'):
  189. try:
  190. # Do not use _call_api(); HTTP Error codes have different meanings for this request
  191. download_data = self._download_json(
  192. f'{self._API_V2_BASE}tracks/{track_id}/download', track_id,
  193. 'Downloading original download format info JSON', query=query, headers=self._HEADERS)
  194. except ExtractorError as e:
  195. if isinstance(e.cause, HTTPError) and e.cause.status == 401:
  196. self.report_warning(
  197. 'Original download format is only available '
  198. f'for registered users. {self._login_hint()}')
  199. elif isinstance(e.cause, HTTPError) and e.cause.status == 403:
  200. self.write_debug('Original download format is not available for this client')
  201. else:
  202. self.report_warning(e.msg)
  203. download_data = None
  204. if redirect_url := traverse_obj(download_data, ('redirectUri', {url_or_none})):
  205. urlh = self._request_webpage(
  206. HEADRequest(redirect_url), track_id, 'Checking original download format availability',
  207. 'Original download format is not available', fatal=False)
  208. if urlh:
  209. format_url = urlh.url
  210. format_urls.add(format_url)
  211. formats.append({
  212. 'format_id': 'download',
  213. 'ext': urlhandle_detect_ext(urlh) or 'mp3',
  214. 'filesize': int_or_none(urlh.headers.get('Content-Length')),
  215. 'url': format_url,
  216. 'quality': 10,
  217. 'format_note': 'Original',
  218. })
  219. def invalid_url(url):
  220. return not url or url in format_urls
  221. def add_format(f, protocol, is_preview=False):
  222. mobj = re.search(r'\.(?P<abr>\d+)\.(?P<ext>[0-9a-z]{3,4})(?=[/?])', stream_url)
  223. if mobj:
  224. for k, v in mobj.groupdict().items():
  225. if not f.get(k):
  226. f[k] = v
  227. format_id_list = []
  228. if protocol:
  229. format_id_list.append(protocol)
  230. ext = f.get('ext')
  231. if ext == 'aac':
  232. f.update({
  233. 'abr': 256,
  234. 'quality': 5,
  235. 'format_note': 'Premium',
  236. })
  237. for k in ('ext', 'abr'):
  238. v = str_or_none(f.get(k))
  239. if v:
  240. format_id_list.append(v)
  241. preview = is_preview or re.search(r'/(?:preview|playlist)/0/30/', f['url'])
  242. if preview:
  243. format_id_list.append('preview')
  244. abr = f.get('abr')
  245. if abr:
  246. f['abr'] = int(abr)
  247. if protocol in ('hls', 'hls-aes'):
  248. protocol = 'm3u8' if ext == 'aac' else 'm3u8_native'
  249. else:
  250. protocol = 'http'
  251. f.update({
  252. 'format_id': '_'.join(format_id_list),
  253. 'protocol': protocol,
  254. 'preference': -10 if preview else None,
  255. })
  256. formats.append(f)
  257. # New API
  258. for t in traverse_obj(info, ('media', 'transcodings', lambda _, v: url_or_none(v['url']))):
  259. if extract_flat:
  260. break
  261. format_url = t['url']
  262. protocol = traverse_obj(t, ('format', 'protocol', {str}))
  263. if protocol == 'progressive':
  264. protocol = 'http'
  265. if protocol != 'hls' and '/hls' in format_url:
  266. protocol = 'hls'
  267. if protocol == 'encrypted-hls' or '/encrypted-hls' in format_url:
  268. protocol = 'hls-aes'
  269. ext = None
  270. if preset := traverse_obj(t, ('preset', {str_or_none})):
  271. ext = preset.split('_')[0]
  272. if ext not in KNOWN_EXTENSIONS:
  273. ext = mimetype2ext(traverse_obj(t, ('format', 'mime_type', {str})))
  274. identifier = join_nonempty(protocol, ext, delim='_')
  275. if not self._is_requested(identifier):
  276. self.write_debug(f'"{identifier}" is not a requested format, skipping')
  277. continue
  278. stream = None
  279. for retry in self.RetryManager(fatal=False):
  280. try:
  281. stream = self._call_api(
  282. format_url, track_id, f'Downloading {identifier} format info JSON',
  283. query=query, headers=self._HEADERS)
  284. except ExtractorError as e:
  285. if isinstance(e.cause, HTTPError) and e.cause.status == 429:
  286. self.report_warning(
  287. 'You have reached the API rate limit, which is ~600 requests per '
  288. '10 minutes. Use the --extractor-retries and --retry-sleep options '
  289. 'to configure an appropriate retry count and wait time', only_once=True)
  290. retry.error = e.cause
  291. else:
  292. self.report_warning(e.msg)
  293. stream_url = traverse_obj(stream, ('url', {url_or_none}))
  294. if invalid_url(stream_url):
  295. continue
  296. format_urls.add(stream_url)
  297. add_format({
  298. 'url': stream_url,
  299. 'ext': ext,
  300. }, protocol, t.get('snipped') or '/preview/' in format_url)
  301. for f in formats:
  302. f['vcodec'] = 'none'
  303. if not formats and info.get('policy') == 'BLOCK':
  304. self.raise_geo_restricted(metadata_available=True)
  305. user = info.get('user') or {}
  306. thumbnails = []
  307. artwork_url = info.get('artwork_url')
  308. thumbnail = artwork_url or user.get('avatar_url')
  309. if isinstance(thumbnail, str):
  310. if re.search(self._IMAGE_REPL_RE, thumbnail):
  311. for image_id, size in self._ARTWORK_MAP.items():
  312. i = {
  313. 'id': image_id,
  314. 'url': re.sub(self._IMAGE_REPL_RE, f'-{image_id}.jpg', thumbnail),
  315. }
  316. if image_id == 'tiny' and not artwork_url:
  317. size = 18
  318. elif image_id == 'original':
  319. i['preference'] = 10
  320. if size:
  321. i.update({
  322. 'width': size,
  323. 'height': size,
  324. })
  325. thumbnails.append(i)
  326. else:
  327. thumbnails = [{'url': thumbnail}]
  328. def extract_count(key):
  329. return int_or_none(info.get(f'{key}_count'))
  330. return {
  331. 'id': track_id,
  332. 'uploader': user.get('username'),
  333. 'uploader_id': str_or_none(user.get('id')) or user.get('permalink'),
  334. 'uploader_url': user.get('permalink_url'),
  335. 'timestamp': unified_timestamp(info.get('created_at')),
  336. 'title': title,
  337. 'description': info.get('description'),
  338. 'thumbnails': thumbnails,
  339. 'duration': float_or_none(info.get('duration'), 1000),
  340. 'webpage_url': info.get('permalink_url'),
  341. 'license': info.get('license'),
  342. 'view_count': extract_count('playback'),
  343. 'like_count': extract_count('favoritings') or extract_count('likes'),
  344. 'comment_count': extract_count('comment'),
  345. 'repost_count': extract_count('reposts'),
  346. 'genres': traverse_obj(info, ('genre', {str}, {lambda x: x or None}, all)),
  347. 'formats': formats if not extract_flat else None,
  348. }
  349. @classmethod
  350. def _resolv_url(cls, url):
  351. return cls._API_V2_BASE + 'resolve?url=' + url
  352. class SoundcloudIE(SoundcloudBaseIE):
  353. """Information extractor for soundcloud.com
  354. To access the media, the uid of the song and a stream token
  355. must be extracted from the page source and the script must make
  356. a request to media.soundcloud.com/crossdomain.xml. Then
  357. the media can be grabbed by requesting from an url composed
  358. of the stream token and uid
  359. """
  360. _VALID_URL = r'''(?x)^(?:https?://)?
  361. (?:(?:(?:www\.|m\.)?soundcloud\.com/
  362. (?!stations/track)
  363. (?P<uploader>[\w\d-]+)/
  364. (?!(?:tracks|albums|sets(?:/.+?)?|reposts|likes|spotlight)/?(?:$|[?#]))
  365. (?P<title>[\w\d-]+)
  366. (?:/(?P<token>(?!(?:albums|sets|recommended))[^?]+?))?
  367. (?:[?].*)?$)
  368. |(?:api(?:-v2)?\.soundcloud\.com/tracks/(?P<track_id>\d+)
  369. (?:/?\?secret_token=(?P<secret_token>[^&]+))?)
  370. )
  371. '''
  372. IE_NAME = 'soundcloud'
  373. _TESTS = [
  374. {
  375. 'url': 'http://soundcloud.com/ethmusic/lostin-powers-she-so-heavy',
  376. 'md5': 'de9bac153e7427a7333b4b0c1b6a18d2',
  377. 'info_dict': {
  378. 'id': '62986583',
  379. 'ext': 'opus',
  380. 'title': 'Lostin Powers - She so Heavy (SneakPreview) Adrian Ackers Blueprint 1',
  381. 'description': 'No Downloads untill we record the finished version this weekend, i was too pumped n i had to post it , earl is prolly gonna b hella p.o\'d',
  382. 'uploader': 'E.T. ExTerrestrial Music',
  383. 'uploader_id': '1571244',
  384. 'timestamp': 1349920598,
  385. 'upload_date': '20121011',
  386. 'duration': 143.216,
  387. 'license': 'all-rights-reserved',
  388. 'view_count': int,
  389. 'like_count': int,
  390. 'comment_count': int,
  391. 'repost_count': int,
  392. 'thumbnail': 'https://i1.sndcdn.com/artworks-000031955188-rwb18x-original.jpg',
  393. 'uploader_url': 'https://soundcloud.com/ethmusic',
  394. 'genres': [],
  395. },
  396. },
  397. # geo-restricted
  398. {
  399. 'url': 'https://soundcloud.com/the-concept-band/goldrushed-mastered?in=the-concept-band/sets/the-royal-concept-ep',
  400. 'info_dict': {
  401. 'id': '47127627',
  402. 'ext': 'opus',
  403. 'title': 'Goldrushed',
  404. 'description': 'From Stockholm Sweden\r\nPovel / Magnus / Filip / David\r\nwww.theroyalconcept.com',
  405. 'uploader': 'The Royal Concept',
  406. 'uploader_id': '9615865',
  407. 'timestamp': 1337635207,
  408. 'upload_date': '20120521',
  409. 'duration': 227.155,
  410. 'license': 'all-rights-reserved',
  411. 'view_count': int,
  412. 'like_count': int,
  413. 'comment_count': int,
  414. 'repost_count': int,
  415. 'uploader_url': 'https://soundcloud.com/the-concept-band',
  416. 'thumbnail': 'https://i1.sndcdn.com/artworks-v8bFHhXm7Au6-0-original.jpg',
  417. 'genres': ['Alternative'],
  418. },
  419. },
  420. # private link
  421. {
  422. 'url': 'https://soundcloud.com/jaimemf/youtube-dl-test-video-a-y-baw/s-8Pjrp',
  423. 'md5': 'aa0dd32bfea9b0c5ef4f02aacd080604',
  424. 'info_dict': {
  425. 'id': '123998367',
  426. 'ext': 'mp3',
  427. 'title': 'Youtube - Dl Test Video \'\' Ä↭',
  428. 'description': 'test chars: "\'/\\ä↭',
  429. 'uploader': 'jaimeMF',
  430. 'uploader_id': '69767071',
  431. 'timestamp': 1386604920,
  432. 'upload_date': '20131209',
  433. 'duration': 9.927,
  434. 'license': 'all-rights-reserved',
  435. 'view_count': int,
  436. 'like_count': int,
  437. 'comment_count': int,
  438. 'repost_count': int,
  439. 'uploader_url': 'https://soundcloud.com/jaimemf',
  440. 'thumbnail': 'https://a1.sndcdn.com/images/default_avatar_large.png',
  441. 'genres': ['youtubedl'],
  442. },
  443. },
  444. # private link (alt format)
  445. {
  446. 'url': 'https://api.soundcloud.com/tracks/123998367?secret_token=s-8Pjrp',
  447. 'md5': 'aa0dd32bfea9b0c5ef4f02aacd080604',
  448. 'info_dict': {
  449. 'id': '123998367',
  450. 'ext': 'mp3',
  451. 'title': 'Youtube - Dl Test Video \'\' Ä↭',
  452. 'description': 'test chars: "\'/\\ä↭',
  453. 'uploader': 'jaimeMF',
  454. 'uploader_id': '69767071',
  455. 'timestamp': 1386604920,
  456. 'upload_date': '20131209',
  457. 'duration': 9.927,
  458. 'license': 'all-rights-reserved',
  459. 'view_count': int,
  460. 'like_count': int,
  461. 'comment_count': int,
  462. 'repost_count': int,
  463. 'uploader_url': 'https://soundcloud.com/jaimemf',
  464. 'thumbnail': 'https://a1.sndcdn.com/images/default_avatar_large.png',
  465. 'genres': ['youtubedl'],
  466. },
  467. },
  468. # downloadable song
  469. {
  470. 'url': 'https://soundcloud.com/the80m/the-following',
  471. 'md5': '9ffcddb08c87d74fb5808a3c183a1d04',
  472. 'info_dict': {
  473. 'id': '343609555',
  474. 'ext': 'wav',
  475. 'title': 'The Following',
  476. 'description': '',
  477. 'uploader': '80M',
  478. 'uploader_id': '312384765',
  479. 'uploader_url': 'https://soundcloud.com/the80m',
  480. 'upload_date': '20170922',
  481. 'timestamp': 1506120436,
  482. 'duration': 397.228,
  483. 'thumbnail': 'https://i1.sndcdn.com/artworks-000243916348-ktoo7d-original.jpg',
  484. 'license': 'all-rights-reserved',
  485. 'like_count': int,
  486. 'comment_count': int,
  487. 'repost_count': int,
  488. 'view_count': int,
  489. 'genres': ['Dance & EDM'],
  490. },
  491. },
  492. # private link, downloadable format
  493. {
  494. 'url': 'https://soundcloud.com/oriuplift/uponly-238-no-talking-wav/s-AyZUd',
  495. 'md5': '64a60b16e617d41d0bef032b7f55441e',
  496. 'info_dict': {
  497. 'id': '340344461',
  498. 'ext': 'wav',
  499. 'title': 'Uplifting Only 238 [No Talking] (incl. Alex Feed Guestmix) (Aug 31, 2017) [wav]',
  500. 'description': 'md5:fa20ee0fca76a3d6df8c7e57f3715366',
  501. 'uploader': 'Ori Uplift Music',
  502. 'uploader_id': '12563093',
  503. 'timestamp': 1504206263,
  504. 'upload_date': '20170831',
  505. 'duration': 7449.096,
  506. 'license': 'all-rights-reserved',
  507. 'view_count': int,
  508. 'like_count': int,
  509. 'comment_count': int,
  510. 'repost_count': int,
  511. 'thumbnail': 'https://i1.sndcdn.com/artworks-000240712245-kedn4p-original.jpg',
  512. 'uploader_url': 'https://soundcloud.com/oriuplift',
  513. 'genres': ['Trance'],
  514. },
  515. },
  516. # no album art, use avatar pic for thumbnail
  517. {
  518. 'url': 'https://soundcloud.com/garyvee/sideways-prod-mad-real',
  519. 'md5': '59c7872bc44e5d99b7211891664760c2',
  520. 'info_dict': {
  521. 'id': '309699954',
  522. 'ext': 'mp3',
  523. 'title': 'Sideways (Prod. Mad Real)',
  524. 'description': 'md5:d41d8cd98f00b204e9800998ecf8427e',
  525. 'uploader': 'garyvee',
  526. 'uploader_id': '2366352',
  527. 'timestamp': 1488152409,
  528. 'upload_date': '20170226',
  529. 'duration': 207.012,
  530. 'thumbnail': r're:https?://.*\.jpg',
  531. 'license': 'all-rights-reserved',
  532. 'view_count': int,
  533. 'like_count': int,
  534. 'comment_count': int,
  535. 'repost_count': int,
  536. 'uploader_url': 'https://soundcloud.com/garyvee',
  537. 'genres': [],
  538. },
  539. 'params': {
  540. 'skip_download': True,
  541. },
  542. },
  543. {
  544. 'url': 'https://soundcloud.com/giovannisarani/mezzo-valzer',
  545. 'md5': '8227c3473a4264df6b02ad7e5b7527ac',
  546. 'info_dict': {
  547. 'id': '583011102',
  548. 'ext': 'opus',
  549. 'title': 'Mezzo Valzer',
  550. 'description': 'md5:f4d5f39d52e0ccc2b4f665326428901a',
  551. 'uploader': 'Giovanni Sarani',
  552. 'uploader_id': '3352531',
  553. 'timestamp': 1551394171,
  554. 'upload_date': '20190228',
  555. 'duration': 180.157,
  556. 'thumbnail': r're:https?://.*\.jpg',
  557. 'license': 'all-rights-reserved',
  558. 'view_count': int,
  559. 'like_count': int,
  560. 'comment_count': int,
  561. 'repost_count': int,
  562. 'genres': ['Piano'],
  563. 'uploader_url': 'https://soundcloud.com/giovannisarani',
  564. },
  565. },
  566. {
  567. # AAC HQ format available (account with active subscription needed)
  568. 'url': 'https://soundcloud.com/wandw/the-chainsmokers-ft-daya-dont-let-me-down-ww-remix-1',
  569. 'only_matching': True,
  570. },
  571. {
  572. # Go+ (account with active subscription needed)
  573. 'url': 'https://soundcloud.com/taylorswiftofficial/look-what-you-made-me-do',
  574. 'only_matching': True,
  575. },
  576. ]
  577. def _real_extract(self, url):
  578. mobj = self._match_valid_url(url)
  579. track_id = mobj.group('track_id')
  580. query = {}
  581. if track_id:
  582. info_json_url = self._API_V2_BASE + 'tracks/' + track_id
  583. full_title = track_id
  584. token = mobj.group('secret_token')
  585. if token:
  586. query['secret_token'] = token
  587. else:
  588. full_title = resolve_title = '{}/{}'.format(*mobj.group('uploader', 'title'))
  589. token = mobj.group('token')
  590. if token:
  591. resolve_title += f'/{token}'
  592. info_json_url = self._resolv_url(self._BASE_URL + resolve_title)
  593. info = self._call_api(
  594. info_json_url, full_title, 'Downloading info JSON', query=query, headers=self._HEADERS)
  595. return self._extract_info_dict(info, full_title, token)
  596. class SoundcloudPlaylistBaseIE(SoundcloudBaseIE):
  597. def _extract_set(self, playlist, token=None):
  598. playlist_id = str(playlist['id'])
  599. tracks = playlist.get('tracks') or []
  600. if not all(t.get('permalink_url') for t in tracks) and token:
  601. tracks = self._call_api(
  602. self._API_V2_BASE + 'tracks', playlist_id,
  603. 'Downloading tracks', query={
  604. 'ids': ','.join([str(t['id']) for t in tracks]),
  605. 'playlistId': playlist_id,
  606. 'playlistSecretToken': token,
  607. }, headers=self._HEADERS)
  608. entries = []
  609. for track in tracks:
  610. track_id = str_or_none(track.get('id'))
  611. url = track.get('permalink_url')
  612. if not url:
  613. if not track_id:
  614. continue
  615. url = self._API_V2_BASE + 'tracks/' + track_id
  616. if token:
  617. url += '?secret_token=' + token
  618. entries.append(self.url_result(
  619. url, SoundcloudIE.ie_key(), track_id))
  620. return self.playlist_result(
  621. entries, playlist_id,
  622. playlist.get('title'),
  623. playlist.get('description'))
  624. class SoundcloudSetIE(SoundcloudPlaylistBaseIE):
  625. _VALID_URL = r'https?://(?:(?:www|m)\.)?soundcloud\.com/(?P<uploader>[\w\d-]+)/sets/(?P<slug_title>[:\w\d-]+)(?:/(?P<token>[^?/]+))?'
  626. IE_NAME = 'soundcloud:set'
  627. _TESTS = [{
  628. 'url': 'https://soundcloud.com/the-concept-band/sets/the-royal-concept-ep',
  629. 'info_dict': {
  630. 'id': '2284613',
  631. 'title': 'The Royal Concept EP',
  632. 'description': 'md5:71d07087c7a449e8941a70a29e34671e',
  633. },
  634. 'playlist_mincount': 5,
  635. }, {
  636. 'url': 'https://soundcloud.com/the-concept-band/sets/the-royal-concept-ep/token',
  637. 'only_matching': True,
  638. }, {
  639. 'url': 'https://soundcloud.com/discover/sets/weekly::flacmatic',
  640. 'only_matching': True,
  641. }, {
  642. 'url': 'https://soundcloud.com/discover/sets/charts-top:all-music:de',
  643. 'only_matching': True,
  644. }, {
  645. 'url': 'https://soundcloud.com/discover/sets/charts-top:hiphoprap:kr',
  646. 'only_matching': True,
  647. }]
  648. def _real_extract(self, url):
  649. mobj = self._match_valid_url(url)
  650. full_title = '{}/sets/{}'.format(*mobj.group('uploader', 'slug_title'))
  651. token = mobj.group('token')
  652. if token:
  653. full_title += '/' + token
  654. info = self._call_api(self._resolv_url(
  655. self._BASE_URL + full_title), full_title, headers=self._HEADERS)
  656. if 'errors' in info:
  657. msgs = (str(err['error_message']) for err in info['errors'])
  658. raise ExtractorError('unable to download video webpage: {}'.format(','.join(msgs)))
  659. return self._extract_set(info, token)
  660. class SoundcloudPagedPlaylistBaseIE(SoundcloudBaseIE):
  661. def _extract_playlist(self, base_url, playlist_id, playlist_title):
  662. return {
  663. '_type': 'playlist',
  664. 'id': playlist_id,
  665. 'title': playlist_title,
  666. 'entries': self._entries(base_url, playlist_id),
  667. }
  668. def _entries(self, url, playlist_id):
  669. # Per the SoundCloud documentation, the maximum limit for a linked partitioning query is 200.
  670. # https://developers.soundcloud.com/blog/offset-pagination-deprecated
  671. query = {
  672. 'limit': 200,
  673. 'linked_partitioning': '1',
  674. 'offset': 0,
  675. }
  676. for i in itertools.count():
  677. for retry in self.RetryManager():
  678. try:
  679. response = self._call_api(
  680. url, playlist_id, query=query, headers=self._HEADERS,
  681. note=f'Downloading track page {i + 1}')
  682. break
  683. except ExtractorError as e:
  684. # Downloading page may result in intermittent 502 HTTP error
  685. # See https://github.com/yt-dlp/yt-dlp/issues/872
  686. if not isinstance(e.cause, HTTPError) or e.cause.status != 502:
  687. raise
  688. retry.error = e
  689. continue
  690. def resolve_entry(*candidates):
  691. for cand in candidates:
  692. if not isinstance(cand, dict):
  693. continue
  694. permalink_url = url_or_none(cand.get('permalink_url'))
  695. if permalink_url:
  696. return self.url_result(
  697. permalink_url,
  698. SoundcloudIE.ie_key() if SoundcloudIE.suitable(permalink_url) else None,
  699. str_or_none(cand.get('id')), cand.get('title'))
  700. for e in response['collection'] or []:
  701. yield resolve_entry(e, e.get('track'), e.get('playlist'))
  702. url = response.get('next_href')
  703. if not url:
  704. break
  705. query.pop('offset', None)
  706. class SoundcloudUserIE(SoundcloudPagedPlaylistBaseIE):
  707. _VALID_URL = r'''(?x)
  708. https?://
  709. (?:(?:www|m)\.)?soundcloud\.com/
  710. (?P<user>[^/]+)
  711. (?:/
  712. (?P<rsrc>tracks|albums|sets|reposts|likes|spotlight)
  713. )?
  714. /?(?:[?#].*)?$
  715. '''
  716. IE_NAME = 'soundcloud:user'
  717. _TESTS = [{
  718. 'url': 'https://soundcloud.com/soft-cell-official',
  719. 'info_dict': {
  720. 'id': '207965082',
  721. 'title': 'Soft Cell (All)',
  722. },
  723. 'playlist_mincount': 28,
  724. }, {
  725. 'url': 'https://soundcloud.com/soft-cell-official/tracks',
  726. 'info_dict': {
  727. 'id': '207965082',
  728. 'title': 'Soft Cell (Tracks)',
  729. },
  730. 'playlist_mincount': 27,
  731. }, {
  732. 'url': 'https://soundcloud.com/soft-cell-official/albums',
  733. 'info_dict': {
  734. 'id': '207965082',
  735. 'title': 'Soft Cell (Albums)',
  736. },
  737. 'playlist_mincount': 1,
  738. }, {
  739. 'url': 'https://soundcloud.com/jcv246/sets',
  740. 'info_dict': {
  741. 'id': '12982173',
  742. 'title': 'Jordi / cv (Sets)',
  743. },
  744. 'playlist_mincount': 2,
  745. }, {
  746. 'url': 'https://soundcloud.com/jcv246/reposts',
  747. 'info_dict': {
  748. 'id': '12982173',
  749. 'title': 'Jordi / cv (Reposts)',
  750. },
  751. 'playlist_mincount': 6,
  752. }, {
  753. 'url': 'https://soundcloud.com/clalberg/likes',
  754. 'info_dict': {
  755. 'id': '11817582',
  756. 'title': 'clalberg (Likes)',
  757. },
  758. 'playlist_mincount': 5,
  759. }, {
  760. 'url': 'https://soundcloud.com/grynpyret/spotlight',
  761. 'info_dict': {
  762. 'id': '7098329',
  763. 'title': 'Grynpyret (Spotlight)',
  764. },
  765. 'playlist_mincount': 1,
  766. }]
  767. _BASE_URL_MAP = {
  768. 'all': 'stream/users/%s',
  769. 'tracks': 'users/%s/tracks',
  770. 'albums': 'users/%s/albums',
  771. 'sets': 'users/%s/playlists',
  772. 'reposts': 'stream/users/%s/reposts',
  773. 'likes': 'users/%s/likes',
  774. 'spotlight': 'users/%s/spotlight',
  775. }
  776. def _real_extract(self, url):
  777. mobj = self._match_valid_url(url)
  778. uploader = mobj.group('user')
  779. user = self._call_api(
  780. self._resolv_url(self._BASE_URL + uploader),
  781. uploader, 'Downloading user info', headers=self._HEADERS)
  782. resource = mobj.group('rsrc') or 'all'
  783. return self._extract_playlist(
  784. self._API_V2_BASE + self._BASE_URL_MAP[resource] % user['id'],
  785. str_or_none(user.get('id')),
  786. '{} ({})'.format(user['username'], resource.capitalize()))
  787. class SoundcloudUserPermalinkIE(SoundcloudPagedPlaylistBaseIE):
  788. _VALID_URL = r'https?://api\.soundcloud\.com/users/(?P<id>\d+)'
  789. IE_NAME = 'soundcloud:user:permalink'
  790. _TESTS = [{
  791. 'url': 'https://api.soundcloud.com/users/30909869',
  792. 'info_dict': {
  793. 'id': '30909869',
  794. 'title': 'neilcic',
  795. },
  796. 'playlist_mincount': 23,
  797. }]
  798. def _real_extract(self, url):
  799. user_id = self._match_id(url)
  800. user = self._call_api(
  801. self._resolv_url(url), user_id, 'Downloading user info', headers=self._HEADERS)
  802. return self._extract_playlist(
  803. f'{self._API_V2_BASE}stream/users/{user["id"]}', str(user['id']), user.get('username'))
  804. class SoundcloudTrackStationIE(SoundcloudPagedPlaylistBaseIE):
  805. _VALID_URL = r'https?://(?:(?:www|m)\.)?soundcloud\.com/stations/track/[^/]+/(?P<id>[^/?#&]+)'
  806. IE_NAME = 'soundcloud:trackstation'
  807. _TESTS = [{
  808. 'url': 'https://soundcloud.com/stations/track/officialsundial/your-text',
  809. 'info_dict': {
  810. 'id': '286017854',
  811. 'title': 'Track station: your text',
  812. },
  813. 'playlist_mincount': 47,
  814. }]
  815. def _real_extract(self, url):
  816. track_name = self._match_id(url)
  817. track = self._call_api(self._resolv_url(url), track_name, headers=self._HEADERS)
  818. track_id = self._search_regex(
  819. r'soundcloud:track-stations:(\d+)', track['id'], 'track id')
  820. return self._extract_playlist(
  821. self._API_V2_BASE + 'stations/{}/tracks'.format(track['id']),
  822. track_id, 'Track station: {}'.format(track['title']))
  823. class SoundcloudRelatedIE(SoundcloudPagedPlaylistBaseIE):
  824. _VALID_URL = r'https?://(?:(?:www|m)\.)?soundcloud\.com/(?P<slug>[\w\d-]+/[\w\d-]+)/(?P<relation>albums|sets|recommended)'
  825. IE_NAME = 'soundcloud:related'
  826. _TESTS = [{
  827. 'url': 'https://soundcloud.com/wajang/sexapil-pingers-5/recommended',
  828. 'info_dict': {
  829. 'id': '1084577272',
  830. 'title': 'Sexapil - Pingers 5 (Recommended)',
  831. },
  832. 'playlist_mincount': 50,
  833. }, {
  834. 'url': 'https://soundcloud.com/wajang/sexapil-pingers-5/albums',
  835. 'info_dict': {
  836. 'id': '1084577272',
  837. 'title': 'Sexapil - Pingers 5 (Albums)',
  838. },
  839. 'playlist_mincount': 1,
  840. }, {
  841. 'url': 'https://soundcloud.com/wajang/sexapil-pingers-5/sets',
  842. 'info_dict': {
  843. 'id': '1084577272',
  844. 'title': 'Sexapil - Pingers 5 (Sets)',
  845. },
  846. 'playlist_mincount': 4,
  847. }]
  848. _BASE_URL_MAP = {
  849. 'albums': 'tracks/%s/albums',
  850. 'sets': 'tracks/%s/playlists_without_albums',
  851. 'recommended': 'tracks/%s/related',
  852. }
  853. def _real_extract(self, url):
  854. slug, relation = self._match_valid_url(url).group('slug', 'relation')
  855. track = self._call_api(
  856. self._resolv_url(self._BASE_URL + slug),
  857. slug, 'Downloading track info', headers=self._HEADERS)
  858. if track.get('errors'):
  859. raise ExtractorError(f'{self.IE_NAME} said: %s' % ','.join(
  860. str(err['error_message']) for err in track['errors']), expected=True)
  861. return self._extract_playlist(
  862. self._API_V2_BASE + self._BASE_URL_MAP[relation] % track['id'], str(track['id']),
  863. '{} ({})'.format(track.get('title') or slug, relation.capitalize()))
  864. class SoundcloudPlaylistIE(SoundcloudPlaylistBaseIE):
  865. _VALID_URL = r'https?://api(?:-v2)?\.soundcloud\.com/playlists/(?P<id>[0-9]+)(?:/?\?secret_token=(?P<token>[^&]+?))?$'
  866. IE_NAME = 'soundcloud:playlist'
  867. _TESTS = [{
  868. 'url': 'https://api.soundcloud.com/playlists/4110309',
  869. 'info_dict': {
  870. 'id': '4110309',
  871. 'title': 'TILT Brass - Bowery Poetry Club, August \'03 [Non-Site SCR 02]',
  872. 'description': 're:.*?TILT Brass - Bowery Poetry Club',
  873. },
  874. 'playlist_count': 6,
  875. }]
  876. def _real_extract(self, url):
  877. mobj = self._match_valid_url(url)
  878. playlist_id = mobj.group('id')
  879. query = {}
  880. token = mobj.group('token')
  881. if token:
  882. query['secret_token'] = token
  883. data = self._call_api(
  884. self._API_V2_BASE + 'playlists/' + playlist_id,
  885. playlist_id, 'Downloading playlist', query=query, headers=self._HEADERS)
  886. return self._extract_set(data, token)
  887. class SoundcloudSearchIE(SoundcloudBaseIE, SearchInfoExtractor):
  888. IE_NAME = 'soundcloud:search'
  889. IE_DESC = 'Soundcloud search'
  890. _SEARCH_KEY = 'scsearch'
  891. _TESTS = [{
  892. 'url': 'scsearch15:post-avant jazzcore',
  893. 'info_dict': {
  894. 'id': 'post-avant jazzcore',
  895. 'title': 'post-avant jazzcore',
  896. },
  897. 'playlist_count': 15,
  898. }]
  899. _MAX_RESULTS_PER_PAGE = 200
  900. _DEFAULT_RESULTS_PER_PAGE = 50
  901. def _get_collection(self, endpoint, collection_id, **query):
  902. limit = min(
  903. query.get('limit', self._DEFAULT_RESULTS_PER_PAGE),
  904. self._MAX_RESULTS_PER_PAGE)
  905. query.update({
  906. 'limit': limit,
  907. 'linked_partitioning': 1,
  908. 'offset': 0,
  909. })
  910. next_url = update_url_query(self._API_V2_BASE + endpoint, query)
  911. for i in itertools.count(1):
  912. response = self._call_api(
  913. next_url, collection_id, f'Downloading page {i}',
  914. 'Unable to download API page', headers=self._HEADERS)
  915. for item in response.get('collection') or []:
  916. if item:
  917. yield self.url_result(
  918. item['uri'], SoundcloudIE.ie_key(), **self._extract_info_dict(item, extract_flat=True))
  919. next_url = response.get('next_href')
  920. if not next_url:
  921. break
  922. def _get_n_results(self, query, n):
  923. return self.playlist_result(itertools.islice(
  924. self._get_collection('search/tracks', query, limit=n, q=query),
  925. 0, None if n == float('inf') else n), query, query)