]> jfr.im git - yt-dlp.git/blame - youtube_dl/extractor/soundcloud.py
[fox] add support for Adobe Pass auth(closes #8584)
[yt-dlp.git] / youtube_dl / extractor / soundcloud.py
CommitLineData
de2dd4c5 1# encoding: utf-8
fbcd7b5f
PH
2from __future__ import unicode_literals
3
aad0d6d5 4import re
92790f4e 5import itertools
aad0d6d5 6
2abf7cab 7from .common import (
8 InfoExtractor,
9 SearchInfoExtractor
10)
1cc79574 11from ..compat import (
aad0d6d5 12 compat_str,
668de34c 13 compat_urlparse,
15707c7e 14 compat_urllib_parse_urlencode,
1cc79574
PH
15)
16from ..utils import (
aad0d6d5 17 ExtractorError,
eb920777 18 int_or_none,
aad0d6d5
PH
19 unified_strdate,
20)
21
22
23class SoundcloudIE(InfoExtractor):
24 """Information extractor for soundcloud.com
25 To access the media, the uid of the song and a stream token
26 must be extracted from the page source and the script must make
27 a request to media.soundcloud.com/crossdomain.xml. Then
28 the media can be grabbed by requesting from an url composed
29 of the stream token and uid
30 """
31
20991253 32 _VALID_URL = r'''(?x)^(?:https?://)?
71507a11 33 (?:(?:(?:www\.|m\.)?soundcloud\.com/
4ff50ef8 34 (?P<uploader>[\w\d-]+)/
f7043ef3 35 (?!(?:tracks|sets(?:/.+?)?|reposts|likes|spotlight)/?(?:$|[?#]))
22a6f150 36 (?P<title>[\w\d-]+)/?
de2dd4c5 37 (?P<token>[^?]+?)?(?:[?].*)?$)
9296738f 38 |(?:api\.soundcloud\.com/tracks/(?P<track_id>\d+)
0403b069 39 (?:/?\?secret_token=(?P<secret_token>[^&]+))?)
31c1cf5a 40 |(?P<player>(?:w|player|p.)\.soundcloud\.com/player/?.*?url=.*)
eb6a41ba
JMF
41 )
42 '''
fbcd7b5f 43 IE_NAME = 'soundcloud'
12c167c8
JMF
44 _TESTS = [
45 {
fbcd7b5f 46 'url': 'http://soundcloud.com/ethmusic/lostin-powers-she-so-heavy',
fbcd7b5f
PH
47 'md5': 'ebef0a451b909710ed1d7787dddbf0d7',
48 'info_dict': {
0eb9fb9f
JMF
49 'id': '62986583',
50 'ext': 'mp3',
51 'upload_date': '20121011',
52 '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',
53 'uploader': 'E.T. ExTerrestrial Music',
54 'title': 'Lostin Powers - She so Heavy (SneakPreview) Adrian Ackers Blueprint 1',
55 'duration': 143,
12c167c8
JMF
56 }
57 },
58 # not streamable song
59 {
fbcd7b5f
PH
60 'url': 'https://soundcloud.com/the-concept-band/goldrushed-mastered?in=the-concept-band/sets/the-royal-concept-ep',
61 'info_dict': {
62 'id': '47127627',
63 'ext': 'mp3',
64 'title': 'Goldrushed',
63ad0315 65 'description': 'From Stockholm Sweden\r\nPovel / Magnus / Filip / David\r\nwww.theroyalconcept.com',
fbcd7b5f
PH
66 'uploader': 'The Royal Concept',
67 'upload_date': '20120521',
eb920777 68 'duration': 227,
12c167c8 69 },
fbcd7b5f 70 'params': {
12c167c8 71 # rtmp
fbcd7b5f 72 'skip_download': True,
12c167c8
JMF
73 },
74 },
de2dd4c5
JMF
75 # private link
76 {
fbcd7b5f
PH
77 'url': 'https://soundcloud.com/jaimemf/youtube-dl-test-video-a-y-baw/s-8Pjrp',
78 'md5': 'aa0dd32bfea9b0c5ef4f02aacd080604',
79 'info_dict': {
80 'id': '123998367',
81 'ext': 'mp3',
82 'title': 'Youtube - Dl Test Video \'\' Ä↭',
83 'uploader': 'jaimeMF',
84 'description': 'test chars: \"\'/\\ä↭',
85 'upload_date': '20131209',
eb920777 86 'duration': 9,
de2dd4c5
JMF
87 },
88 },
9296738f 89 # private link (alt format)
90 {
91 'url': 'https://api.soundcloud.com/tracks/123998367?secret_token=s-8Pjrp',
92 'md5': 'aa0dd32bfea9b0c5ef4f02aacd080604',
93 'info_dict': {
94 'id': '123998367',
95 'ext': 'mp3',
96 'title': 'Youtube - Dl Test Video \'\' Ä↭',
97 'uploader': 'jaimeMF',
98 'description': 'test chars: \"\'/\\ä↭',
99 'upload_date': '20131209',
100 'duration': 9,
101 },
102 },
f67ca84d
JMF
103 # downloadable song
104 {
00a82ea8 105 'url': 'https://soundcloud.com/oddsamples/bus-brakes',
eae12e3f 106 'md5': '7624f2351f8a3b2e7cd51522496e7631',
fbcd7b5f 107 'info_dict': {
00a82ea8 108 'id': '128590877',
eae12e3f 109 'ext': 'mp3',
00a82ea8 110 'title': 'Bus Brakes',
0eb9fb9f 111 'description': 'md5:0053ca6396e8d2fd7b7e1595ef12ab66',
00a82ea8
S
112 'uploader': 'oddsamples',
113 'upload_date': '20140109',
114 'duration': 17,
f67ca84d
JMF
115 },
116 },
12c167c8 117 ]
aad0d6d5 118
eb11cbe8 119 _CLIENT_ID = '02gUJC0hH2ct1EGOcYXQIzRFU91c72Ea'
64bb5187 120 _IPHONE_CLIENT_ID = '376f225bf427445fc4bfb6b99b72e0bf'
7d239269 121
fbdf8d15
S
122 @staticmethod
123 def _extract_urls(webpage):
124 return [m.group('url') for m in re.finditer(
125 r'<iframe[^>]+src=(["\'])(?P<url>(?:https?://)?(?:w\.)?soundcloud\.com/player.+?)\1',
126 webpage)]
127
aad0d6d5
PH
128 def report_resolve(self, video_id):
129 """Report information extraction."""
83622b6d 130 self.to_screen('%s: Resolving id' % video_id)
aad0d6d5 131
7d239269
JMF
132 @classmethod
133 def _resolv_url(cls, url):
134 return 'http://api.soundcloud.com/resolve.json?url=' + url + '&client_id=' + cls._CLIENT_ID
135
de2dd4c5 136 def _extract_info_dict(self, info, full_title=None, quiet=False, secret_token=None):
12c167c8
JMF
137 track_id = compat_str(info['id'])
138 name = full_title or track_id
2a15e706 139 if quiet:
92790f4e 140 self.report_extraction(name)
7d239269
JMF
141
142 thumbnail = info['artwork_url']
143 if thumbnail is not None:
144 thumbnail = thumbnail.replace('-large', '-t500x500')
fbcd7b5f 145 ext = 'mp3'
12c167c8 146 result = {
2a15e706 147 'id': track_id,
7d239269
JMF
148 'uploader': info['user']['username'],
149 'upload_date': unified_strdate(info['created_at']),
2a15e706 150 'title': info['title'],
7d239269
JMF
151 'description': info['description'],
152 'thumbnail': thumbnail,
eb920777 153 'duration': int_or_none(info.get('duration'), 1000),
579657ad 154 'webpage_url': info.get('permalink_url'),
7d239269 155 }
5e114e4b 156 formats = []
12c167c8 157 if info.get('downloadable', False):
64bb5187 158 # We can build a direct link to the song
2a15e706 159 format_url = (
fbcd7b5f 160 'https://api.soundcloud.com/tracks/{0}/download?client_id={1}'.format(
2a15e706 161 track_id, self._CLIENT_ID))
5e114e4b 162 formats.append({
2a15e706 163 'format_id': 'download',
fbcd7b5f 164 'ext': info.get('original_format', 'mp3'),
2a15e706 165 'url': format_url,
fb04e403 166 'vcodec': 'none',
5e114e4b
PH
167 'preference': 10,
168 })
169
170 # We have to retrieve the url
171 streams_url = ('http://api.soundcloud.com/i1/tracks/{0}/streams?'
9e1a5b84 172 'client_id={1}&secret_token={2}'.format(track_id, self._IPHONE_CLIENT_ID, secret_token))
20991253 173 format_dict = self._download_json(
5e114e4b
PH
174 streams_url,
175 track_id, 'Downloading track url')
176
5e114e4b
PH
177 for key, stream_url in format_dict.items():
178 if key.startswith('http'):
179 formats.append({
180 'format_id': key,
181 'ext': ext,
182 'url': stream_url,
183 'vcodec': 'none',
184 })
185 elif key.startswith('rtmp'):
186 # The url doesn't have an rtmp app, we have to extract the playpath
187 url, path = stream_url.split('mp3:', 1)
188 formats.append({
189 'format_id': key,
190 'url': url,
191 'play_path': 'mp3:' + path,
295df4ed 192 'ext': 'flv',
5e114e4b
PH
193 'vcodec': 'none',
194 })
2a15e706
PH
195
196 if not formats:
64bb5187
JMF
197 # We fallback to the stream_url in the original info, this
198 # cannot be always used, sometimes it can give an HTTP 404 error
2a15e706 199 formats.append({
fbcd7b5f 200 'format_id': 'fallback',
2a15e706
PH
201 'url': info['stream_url'] + '?client_id=' + self._CLIENT_ID,
202 'ext': ext,
fb04e403 203 'vcodec': 'none',
2a15e706
PH
204 })
205
fbcd7b5f 206 for f in formats:
2a15e706 207 if f['format_id'].startswith('http'):
fbcd7b5f 208 f['protocol'] = 'http'
2a15e706 209 if f['format_id'].startswith('rtmp'):
fbcd7b5f 210 f['protocol'] = 'rtmp'
2a15e706 211
562ceab1
S
212 self._check_formats(formats, track_id)
213 self._sort_formats(formats)
214 result['formats'] = formats
64bb5187 215
12c167c8 216 return result
7d239269 217
aad0d6d5 218 def _real_extract(self, url):
eb6a41ba 219 mobj = re.match(self._VALID_URL, url, flags=re.VERBOSE)
aad0d6d5 220 if mobj is None:
83622b6d 221 raise ExtractorError('Invalid URL: %s' % url)
aad0d6d5 222
eb6a41ba 223 track_id = mobj.group('track_id')
de2dd4c5 224 token = None
eb6a41ba
JMF
225 if track_id is not None:
226 info_json_url = 'http://api.soundcloud.com/tracks/' + track_id + '.json?client_id=' + self._CLIENT_ID
227 full_title = track_id
9296738f 228 token = mobj.group('secret_token')
229 if token:
611c1dd9 230 info_json_url += '&secret_token=' + token
31c1cf5a 231 elif mobj.group('player'):
668de34c 232 query = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
024ebb27
JMF
233 real_url = query['url'][0]
234 # If the token is in the query of the original url we have to
235 # manually add it
236 if 'secret_token' in query:
237 real_url += '?secret_token=' + query['secret_token'][0]
238 return self.url_result(real_url)
eb6a41ba
JMF
239 else:
240 # extract uploader (which is in the url)
de2dd4c5 241 uploader = mobj.group('uploader')
eb6a41ba 242 # extract simple title (uploader + slug of song title)
8bcc8756 243 slug_title = mobj.group('title')
de2dd4c5
JMF
244 token = mobj.group('token')
245 full_title = resolve_title = '%s/%s' % (uploader, slug_title)
246 if token:
247 resolve_title += '/%s' % token
5f6a1245 248
eb6a41ba 249 self.report_resolve(full_title)
5f6a1245 250
de2dd4c5 251 url = 'http://soundcloud.com/%s' % resolve_title
eb6a41ba 252 info_json_url = self._resolv_url(url)
20991253 253 info = self._download_json(info_json_url, full_title, 'Downloading info JSON')
aad0d6d5 254
de2dd4c5 255 return self._extract_info_dict(info, full_title, secret_token=token)
aad0d6d5 256
20991253 257
7d239269 258class SoundcloudSetIE(SoundcloudIE):
c808ef81 259 _VALID_URL = r'https?://(?:(?:www|m)\.)?soundcloud\.com/(?P<uploader>[\w\d-]+)/sets/(?P<slug_title>[\w\d-]+)(?:/(?P<token>[^?/]+))?'
fbcd7b5f 260 IE_NAME = 'soundcloud:set'
22a6f150
PH
261 _TESTS = [{
262 'url': 'https://soundcloud.com/the-concept-band/sets/the-royal-concept-ep',
263 'info_dict': {
a9551e90 264 'id': '2284613',
22a6f150
PH
265 'title': 'The Royal Concept EP',
266 },
267 'playlist_mincount': 6,
f7043ef3
S
268 }, {
269 'url': 'https://soundcloud.com/the-concept-band/sets/the-royal-concept-ep/token',
270 'only_matching': True,
22a6f150 271 }]
aad0d6d5 272
aad0d6d5
PH
273 def _real_extract(self, url):
274 mobj = re.match(self._VALID_URL, url)
aad0d6d5
PH
275
276 # extract uploader (which is in the url)
2f834e93 277 uploader = mobj.group('uploader')
aad0d6d5 278 # extract simple title (uploader + slug of song title)
2f834e93 279 slug_title = mobj.group('slug_title')
aad0d6d5 280 full_title = '%s/sets/%s' % (uploader, slug_title)
2f834e93 281 url = 'http://soundcloud.com/%s/sets/%s' % (uploader, slug_title)
282
283 token = mobj.group('token')
284 if token:
285 full_title += '/' + token
286 url += '/' + token
aad0d6d5
PH
287
288 self.report_resolve(full_title)
289
7d239269 290 resolv_url = self._resolv_url(url)
20991253 291 info = self._download_json(resolv_url, full_title)
aad0d6d5 292
aad0d6d5 293 if 'errors' in info:
214e74bf
JMF
294 msgs = (compat_str(err['error_message']) for err in info['errors'])
295 raise ExtractorError('unable to download video webpage: %s' % ','.join(msgs))
aad0d6d5 296
b14fa8e6
S
297 entries = [self.url_result(track['permalink_url'], 'Soundcloud') for track in info['tracks']]
298
22a6f150
PH
299 return {
300 '_type': 'playlist',
b14fa8e6 301 'entries': entries,
a9551e90 302 'id': '%s' % info['id'],
22a6f150
PH
303 'title': info['title'],
304 }
92790f4e
JMF
305
306
307class SoundcloudUserIE(SoundcloudIE):
16a08978
S
308 _VALID_URL = r'''(?x)
309 https?://
310 (?:(?:www|m)\.)?soundcloud\.com/
311 (?P<user>[^/]+)
312 (?:/
313 (?P<rsrc>tracks|sets|reposts|likes|spotlight)
314 )?
315 /?(?:[?#].*)?$
316 '''
fbcd7b5f 317 IE_NAME = 'soundcloud:user'
22a6f150 318 _TESTS = [{
80fb6d4a 319 'url': 'https://soundcloud.com/the-akashic-chronicler',
22a6f150 320 'info_dict': {
80fb6d4a
S
321 'id': '114582580',
322 'title': 'The Akashic Chronicler (All)',
22a6f150 323 },
66ce9702 324 'playlist_mincount': 111,
22a6f150 325 }, {
80fb6d4a 326 'url': 'https://soundcloud.com/the-akashic-chronicler/tracks',
22a6f150 327 'info_dict': {
80fb6d4a
S
328 'id': '114582580',
329 'title': 'The Akashic Chronicler (Tracks)',
22a6f150 330 },
80fb6d4a 331 'playlist_mincount': 50,
03b9c944 332 }, {
80fb6d4a
S
333 'url': 'https://soundcloud.com/the-akashic-chronicler/sets',
334 'info_dict': {
335 'id': '114582580',
336 'title': 'The Akashic Chronicler (Playlists)',
337 },
338 'playlist_mincount': 3,
339 }, {
340 'url': 'https://soundcloud.com/the-akashic-chronicler/reposts',
341 'info_dict': {
342 'id': '114582580',
343 'title': 'The Akashic Chronicler (Reposts)',
344 },
66ce9702 345 'playlist_mincount': 7,
80fb6d4a
S
346 }, {
347 'url': 'https://soundcloud.com/the-akashic-chronicler/likes',
348 'info_dict': {
349 'id': '114582580',
350 'title': 'The Akashic Chronicler (Likes)',
351 },
66ce9702 352 'playlist_mincount': 321,
80fb6d4a
S
353 }, {
354 'url': 'https://soundcloud.com/grynpyret/spotlight',
355 'info_dict': {
356 'id': '7098329',
357 'title': 'Grynpyret (Spotlight)',
358 },
359 'playlist_mincount': 1,
22a6f150 360 }]
92790f4e 361
80fb6d4a
S
362 _API_BASE = 'https://api.soundcloud.com'
363 _API_V2_BASE = 'https://api-v2.soundcloud.com'
364
365 _BASE_URL_MAP = {
366 'all': '%s/profile/soundcloud:users:%%s' % _API_V2_BASE,
367 'tracks': '%s/users/%%s/tracks' % _API_BASE,
368 'sets': '%s/users/%%s/playlists' % _API_V2_BASE,
369 'reposts': '%s/profile/soundcloud:users:%%s/reposts' % _API_V2_BASE,
370 'likes': '%s/users/%%s/likes' % _API_V2_BASE,
371 'spotlight': '%s/users/%%s/spotlight' % _API_V2_BASE,
372 }
373
374 _TITLE_MAP = {
375 'all': 'All',
376 'tracks': 'Tracks',
377 'sets': 'Playlists',
378 'reposts': 'Reposts',
379 'likes': 'Likes',
380 'spotlight': 'Spotlight',
381 }
382
92790f4e
JMF
383 def _real_extract(self, url):
384 mobj = re.match(self._VALID_URL, url)
385 uploader = mobj.group('user')
386
387 url = 'http://soundcloud.com/%s/' % uploader
388 resolv_url = self._resolv_url(url)
20991253
PH
389 user = self._download_json(
390 resolv_url, uploader, 'Downloading user info')
80fb6d4a
S
391
392 resource = mobj.group('rsrc') or 'all'
393 base_url = self._BASE_URL_MAP[resource] % user['id']
394
97afd99a
S
395 COMMON_QUERY = {
396 'limit': 50,
397 'client_id': self._CLIENT_ID,
398 'linked_partitioning': '1',
399 }
400
401 query = COMMON_QUERY.copy()
402 query['offset'] = 0
403
15707c7e 404 next_href = base_url + '?' + compat_urllib_parse_urlencode(query)
92790f4e 405
20991253 406 entries = []
92790f4e 407 for i in itertools.count():
80fb6d4a
S
408 response = self._download_json(
409 next_href, uploader, 'Downloading track page %s' % (i + 1))
410
411 collection = response['collection']
80fb6d4a 412 if not collection:
92790f4e 413 break
80fb6d4a
S
414
415 def resolve_permalink_url(candidates):
416 for cand in candidates:
417 if isinstance(cand, dict):
418 permalink_url = cand.get('permalink_url')
419 if permalink_url and permalink_url.startswith('http'):
420 return permalink_url
421
422 for e in collection:
423 permalink_url = resolve_permalink_url((e, e.get('track'), e.get('playlist')))
424 if permalink_url:
425 entries.append(self.url_result(permalink_url))
426
97afd99a
S
427 next_href = response.get('next_href')
428 if not next_href:
429 break
430
431 parsed_next_href = compat_urlparse.urlparse(response['next_href'])
432 qs = compat_urlparse.parse_qs(parsed_next_href.query)
433 qs.update(COMMON_QUERY)
434 next_href = compat_urlparse.urlunparse(
15707c7e 435 parsed_next_href._replace(query=compat_urllib_parse_urlencode(qs, True)))
92790f4e
JMF
436
437 return {
438 '_type': 'playlist',
439 'id': compat_str(user['id']),
80fb6d4a 440 'title': '%s (%s)' % (user['username'], self._TITLE_MAP[resource]),
20991253
PH
441 'entries': entries,
442 }
443
444
445class SoundcloudPlaylistIE(SoundcloudIE):
46f74bcf 446 _VALID_URL = r'https?://api\.soundcloud\.com/playlists/(?P<id>[0-9]+)(?:/?\?secret_token=(?P<token>[^&]+?))?$'
20991253 447 IE_NAME = 'soundcloud:playlist'
46f74bcf
PH
448 _TESTS = [{
449 'url': 'http://api.soundcloud.com/playlists/4110309',
450 'info_dict': {
451 'id': '4110309',
452 'title': 'TILT Brass - Bowery Poetry Club, August \'03 [Non-Site SCR 02]',
453 'description': 're:.*?TILT Brass - Bowery Poetry Club',
454 },
455 'playlist_count': 6,
456 }]
20991253
PH
457
458 def _real_extract(self, url):
459 mobj = re.match(self._VALID_URL, url)
460 playlist_id = mobj.group('id')
461 base_url = '%s//api.soundcloud.com/playlists/%s.json?' % (self.http_scheme(), playlist_id)
462
2f834e93 463 data_dict = {
20991253 464 'client_id': self._CLIENT_ID,
2f834e93 465 }
466 token = mobj.group('token')
467
468 if token:
469 data_dict['secret_token'] = token
470
15707c7e 471 data = compat_urllib_parse_urlencode(data_dict)
20991253
PH
472 data = self._download_json(
473 base_url + data, playlist_id, 'Downloading playlist')
474
40a2d170 475 entries = [self.url_result(track['permalink_url'], 'Soundcloud') for track in data['tracks']]
20991253
PH
476
477 return {
478 '_type': 'playlist',
479 'id': playlist_id,
480 'title': data.get('title'),
481 'description': data.get('description'),
482 'entries': entries,
92790f4e 483 }
2abf7cab 484
485
486class SoundcloudSearchIE(SearchInfoExtractor, SoundcloudIE):
487 IE_NAME = 'soundcloud:search'
488 IE_DESC = 'Soundcloud search'
328a22e1 489 _MAX_RESULTS = float('inf')
2abf7cab 490 _TESTS = [{
491 'url': 'scsearch15:post-avant jazzcore',
492 'info_dict': {
493 'title': 'post-avant jazzcore',
494 },
495 'playlist_count': 15,
496 }]
497
498 _SEARCH_KEY = 'scsearch'
328a22e1 499 _MAX_RESULTS_PER_PAGE = 200
500 _DEFAULT_RESULTS_PER_PAGE = 50
c30943b1 501 _API_V2_BASE = 'https://api-v2.soundcloud.com'
2abf7cab 502
503 def _get_collection(self, endpoint, collection_id, **query):
a3372437 504 limit = min(
328a22e1 505 query.get('limit', self._DEFAULT_RESULTS_PER_PAGE),
506 self._MAX_RESULTS_PER_PAGE)
f6c903e7 507 query['limit'] = limit
2abf7cab 508 query['client_id'] = self._CLIENT_ID
509 query['linked_partitioning'] = '1'
f6c903e7 510 query['offset'] = 0
15707c7e 511 data = compat_urllib_parse_urlencode(query)
f6c903e7 512 next_url = '{0}{1}?{2}'.format(self._API_V2_BASE, endpoint, data)
2abf7cab 513
2abf7cab 514 collected_results = 0
515
f6c903e7 516 for i in itertools.count(1):
7e347275 517 response = self._download_json(
f6c903e7 518 next_url, collection_id, 'Downloading page {0}'.format(i),
7e347275 519 'Unable to download API page')
2abf7cab 520
f6c903e7
S
521 collection = response.get('collection', [])
522 if not collection:
523 break
2abf7cab 524
f6c903e7 525 collection = list(filter(bool, collection))
2abf7cab 526 collected_results += len(collection)
527
f6c903e7
S
528 for item in collection:
529 yield self.url_result(item['uri'], SoundcloudIE.ie_key())
2abf7cab 530
f6c903e7 531 if not collection or collected_results >= limit:
2abf7cab 532 break
533
7e347275 534 next_url = response.get('next_href')
f6c903e7
S
535 if not next_url:
536 break
2abf7cab 537
538 def _get_n_results(self, query, n):
f6c903e7
S
539 tracks = self._get_collection('/search/tracks', query, limit=n, q=query)
540 return self.playlist_result(tracks, playlist_title=query)