]> jfr.im git - yt-dlp.git/blame - youtube_dl/extractor/soundcloud.py
[soundcloud] Use correct error message conventions
[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,
92790f4e 14 compat_urllib_parse,
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-]+)/
16a08978 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
aad0d6d5
PH
122 def report_resolve(self, video_id):
123 """Report information extraction."""
83622b6d 124 self.to_screen('%s: Resolving id' % video_id)
aad0d6d5 125
7d239269
JMF
126 @classmethod
127 def _resolv_url(cls, url):
128 return 'http://api.soundcloud.com/resolve.json?url=' + url + '&client_id=' + cls._CLIENT_ID
129
de2dd4c5 130 def _extract_info_dict(self, info, full_title=None, quiet=False, secret_token=None):
12c167c8
JMF
131 track_id = compat_str(info['id'])
132 name = full_title or track_id
2a15e706 133 if quiet:
92790f4e 134 self.report_extraction(name)
7d239269
JMF
135
136 thumbnail = info['artwork_url']
137 if thumbnail is not None:
138 thumbnail = thumbnail.replace('-large', '-t500x500')
fbcd7b5f 139 ext = 'mp3'
12c167c8 140 result = {
2a15e706 141 'id': track_id,
7d239269
JMF
142 'uploader': info['user']['username'],
143 'upload_date': unified_strdate(info['created_at']),
2a15e706 144 'title': info['title'],
7d239269
JMF
145 'description': info['description'],
146 'thumbnail': thumbnail,
eb920777 147 'duration': int_or_none(info.get('duration'), 1000),
579657ad 148 'webpage_url': info.get('permalink_url'),
7d239269 149 }
5e114e4b 150 formats = []
12c167c8 151 if info.get('downloadable', False):
64bb5187 152 # We can build a direct link to the song
2a15e706 153 format_url = (
fbcd7b5f 154 'https://api.soundcloud.com/tracks/{0}/download?client_id={1}'.format(
2a15e706 155 track_id, self._CLIENT_ID))
5e114e4b 156 formats.append({
2a15e706 157 'format_id': 'download',
fbcd7b5f 158 'ext': info.get('original_format', 'mp3'),
2a15e706 159 'url': format_url,
fb04e403 160 'vcodec': 'none',
5e114e4b
PH
161 'preference': 10,
162 })
163
164 # We have to retrieve the url
165 streams_url = ('http://api.soundcloud.com/i1/tracks/{0}/streams?'
9e1a5b84 166 'client_id={1}&secret_token={2}'.format(track_id, self._IPHONE_CLIENT_ID, secret_token))
20991253 167 format_dict = self._download_json(
5e114e4b
PH
168 streams_url,
169 track_id, 'Downloading track url')
170
5e114e4b
PH
171 for key, stream_url in format_dict.items():
172 if key.startswith('http'):
173 formats.append({
174 'format_id': key,
175 'ext': ext,
176 'url': stream_url,
177 'vcodec': 'none',
178 })
179 elif key.startswith('rtmp'):
180 # The url doesn't have an rtmp app, we have to extract the playpath
181 url, path = stream_url.split('mp3:', 1)
182 formats.append({
183 'format_id': key,
184 'url': url,
185 'play_path': 'mp3:' + path,
295df4ed 186 'ext': 'flv',
5e114e4b
PH
187 'vcodec': 'none',
188 })
2a15e706
PH
189
190 if not formats:
64bb5187
JMF
191 # We fallback to the stream_url in the original info, this
192 # cannot be always used, sometimes it can give an HTTP 404 error
2a15e706 193 formats.append({
fbcd7b5f 194 'format_id': 'fallback',
2a15e706
PH
195 'url': info['stream_url'] + '?client_id=' + self._CLIENT_ID,
196 'ext': ext,
fb04e403 197 'vcodec': 'none',
2a15e706
PH
198 })
199
fbcd7b5f 200 for f in formats:
2a15e706 201 if f['format_id'].startswith('http'):
fbcd7b5f 202 f['protocol'] = 'http'
2a15e706 203 if f['format_id'].startswith('rtmp'):
fbcd7b5f 204 f['protocol'] = 'rtmp'
2a15e706 205
562ceab1
S
206 self._check_formats(formats, track_id)
207 self._sort_formats(formats)
208 result['formats'] = formats
64bb5187 209
12c167c8 210 return result
7d239269 211
aad0d6d5 212 def _real_extract(self, url):
eb6a41ba 213 mobj = re.match(self._VALID_URL, url, flags=re.VERBOSE)
aad0d6d5 214 if mobj is None:
83622b6d 215 raise ExtractorError('Invalid URL: %s' % url)
aad0d6d5 216
eb6a41ba 217 track_id = mobj.group('track_id')
de2dd4c5 218 token = None
eb6a41ba
JMF
219 if track_id is not None:
220 info_json_url = 'http://api.soundcloud.com/tracks/' + track_id + '.json?client_id=' + self._CLIENT_ID
221 full_title = track_id
9296738f 222 token = mobj.group('secret_token')
223 if token:
224 info_json_url += "&secret_token=" + token
31c1cf5a 225 elif mobj.group('player'):
668de34c 226 query = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
024ebb27
JMF
227 real_url = query['url'][0]
228 # If the token is in the query of the original url we have to
229 # manually add it
230 if 'secret_token' in query:
231 real_url += '?secret_token=' + query['secret_token'][0]
232 return self.url_result(real_url)
eb6a41ba
JMF
233 else:
234 # extract uploader (which is in the url)
de2dd4c5 235 uploader = mobj.group('uploader')
eb6a41ba 236 # extract simple title (uploader + slug of song title)
8bcc8756 237 slug_title = mobj.group('title')
de2dd4c5
JMF
238 token = mobj.group('token')
239 full_title = resolve_title = '%s/%s' % (uploader, slug_title)
240 if token:
241 resolve_title += '/%s' % token
5f6a1245 242
eb6a41ba 243 self.report_resolve(full_title)
5f6a1245 244
de2dd4c5 245 url = 'http://soundcloud.com/%s' % resolve_title
eb6a41ba 246 info_json_url = self._resolv_url(url)
20991253 247 info = self._download_json(info_json_url, full_title, 'Downloading info JSON')
aad0d6d5 248
de2dd4c5 249 return self._extract_info_dict(info, full_title, secret_token=token)
aad0d6d5 250
20991253 251
7d239269 252class SoundcloudSetIE(SoundcloudIE):
c808ef81 253 _VALID_URL = r'https?://(?:(?:www|m)\.)?soundcloud\.com/(?P<uploader>[\w\d-]+)/sets/(?P<slug_title>[\w\d-]+)(?:/(?P<token>[^?/]+))?'
fbcd7b5f 254 IE_NAME = 'soundcloud:set'
22a6f150
PH
255 _TESTS = [{
256 'url': 'https://soundcloud.com/the-concept-band/sets/the-royal-concept-ep',
257 'info_dict': {
a9551e90 258 'id': '2284613',
22a6f150
PH
259 'title': 'The Royal Concept EP',
260 },
261 'playlist_mincount': 6,
262 }]
aad0d6d5 263
aad0d6d5
PH
264 def _real_extract(self, url):
265 mobj = re.match(self._VALID_URL, url)
aad0d6d5
PH
266
267 # extract uploader (which is in the url)
2f834e93 268 uploader = mobj.group('uploader')
aad0d6d5 269 # extract simple title (uploader + slug of song title)
2f834e93 270 slug_title = mobj.group('slug_title')
aad0d6d5 271 full_title = '%s/sets/%s' % (uploader, slug_title)
2f834e93 272 url = 'http://soundcloud.com/%s/sets/%s' % (uploader, slug_title)
273
274 token = mobj.group('token')
275 if token:
276 full_title += '/' + token
277 url += '/' + token
aad0d6d5
PH
278
279 self.report_resolve(full_title)
280
7d239269 281 resolv_url = self._resolv_url(url)
20991253 282 info = self._download_json(resolv_url, full_title)
aad0d6d5 283
aad0d6d5 284 if 'errors' in info:
214e74bf
JMF
285 msgs = (compat_str(err['error_message']) for err in info['errors'])
286 raise ExtractorError('unable to download video webpage: %s' % ','.join(msgs))
aad0d6d5 287
b14fa8e6
S
288 entries = [self.url_result(track['permalink_url'], 'Soundcloud') for track in info['tracks']]
289
22a6f150
PH
290 return {
291 '_type': 'playlist',
b14fa8e6 292 'entries': entries,
a9551e90 293 'id': '%s' % info['id'],
22a6f150
PH
294 'title': info['title'],
295 }
92790f4e
JMF
296
297
298class SoundcloudUserIE(SoundcloudIE):
16a08978
S
299 _VALID_URL = r'''(?x)
300 https?://
301 (?:(?:www|m)\.)?soundcloud\.com/
302 (?P<user>[^/]+)
303 (?:/
304 (?P<rsrc>tracks|sets|reposts|likes|spotlight)
305 )?
306 /?(?:[?#].*)?$
307 '''
fbcd7b5f 308 IE_NAME = 'soundcloud:user'
22a6f150 309 _TESTS = [{
80fb6d4a 310 'url': 'https://soundcloud.com/the-akashic-chronicler',
22a6f150 311 'info_dict': {
80fb6d4a
S
312 'id': '114582580',
313 'title': 'The Akashic Chronicler (All)',
22a6f150 314 },
66ce9702 315 'playlist_mincount': 111,
22a6f150 316 }, {
80fb6d4a 317 'url': 'https://soundcloud.com/the-akashic-chronicler/tracks',
22a6f150 318 'info_dict': {
80fb6d4a
S
319 'id': '114582580',
320 'title': 'The Akashic Chronicler (Tracks)',
22a6f150 321 },
80fb6d4a 322 'playlist_mincount': 50,
03b9c944 323 }, {
80fb6d4a
S
324 'url': 'https://soundcloud.com/the-akashic-chronicler/sets',
325 'info_dict': {
326 'id': '114582580',
327 'title': 'The Akashic Chronicler (Playlists)',
328 },
329 'playlist_mincount': 3,
330 }, {
331 'url': 'https://soundcloud.com/the-akashic-chronicler/reposts',
332 'info_dict': {
333 'id': '114582580',
334 'title': 'The Akashic Chronicler (Reposts)',
335 },
66ce9702 336 'playlist_mincount': 7,
80fb6d4a
S
337 }, {
338 'url': 'https://soundcloud.com/the-akashic-chronicler/likes',
339 'info_dict': {
340 'id': '114582580',
341 'title': 'The Akashic Chronicler (Likes)',
342 },
66ce9702 343 'playlist_mincount': 321,
80fb6d4a
S
344 }, {
345 'url': 'https://soundcloud.com/grynpyret/spotlight',
346 'info_dict': {
347 'id': '7098329',
348 'title': 'Grynpyret (Spotlight)',
349 },
350 'playlist_mincount': 1,
22a6f150 351 }]
92790f4e 352
80fb6d4a
S
353 _API_BASE = 'https://api.soundcloud.com'
354 _API_V2_BASE = 'https://api-v2.soundcloud.com'
355
356 _BASE_URL_MAP = {
357 'all': '%s/profile/soundcloud:users:%%s' % _API_V2_BASE,
358 'tracks': '%s/users/%%s/tracks' % _API_BASE,
359 'sets': '%s/users/%%s/playlists' % _API_V2_BASE,
360 'reposts': '%s/profile/soundcloud:users:%%s/reposts' % _API_V2_BASE,
361 'likes': '%s/users/%%s/likes' % _API_V2_BASE,
362 'spotlight': '%s/users/%%s/spotlight' % _API_V2_BASE,
363 }
364
365 _TITLE_MAP = {
366 'all': 'All',
367 'tracks': 'Tracks',
368 'sets': 'Playlists',
369 'reposts': 'Reposts',
370 'likes': 'Likes',
371 'spotlight': 'Spotlight',
372 }
373
92790f4e
JMF
374 def _real_extract(self, url):
375 mobj = re.match(self._VALID_URL, url)
376 uploader = mobj.group('user')
377
378 url = 'http://soundcloud.com/%s/' % uploader
379 resolv_url = self._resolv_url(url)
20991253
PH
380 user = self._download_json(
381 resolv_url, uploader, 'Downloading user info')
80fb6d4a
S
382
383 resource = mobj.group('rsrc') or 'all'
384 base_url = self._BASE_URL_MAP[resource] % user['id']
385
386 next_href = None
92790f4e 387
20991253 388 entries = []
92790f4e 389 for i in itertools.count():
80fb6d4a
S
390 if not next_href:
391 data = compat_urllib_parse.urlencode({
392 'offset': i * 50,
393 'limit': 50,
394 'client_id': self._CLIENT_ID,
395 'linked_partitioning': '1',
396 'representation': 'speedy',
397 })
398 next_href = base_url + '?' + data
399
400 response = self._download_json(
401 next_href, uploader, 'Downloading track page %s' % (i + 1))
402
403 collection = response['collection']
404
405 if not collection:
3941669d 406 self.to_screen('%s: End page received' % uploader)
92790f4e 407 break
80fb6d4a
S
408
409 def resolve_permalink_url(candidates):
410 for cand in candidates:
411 if isinstance(cand, dict):
412 permalink_url = cand.get('permalink_url')
413 if permalink_url and permalink_url.startswith('http'):
414 return permalink_url
415
416 for e in collection:
417 permalink_url = resolve_permalink_url((e, e.get('track'), e.get('playlist')))
418 if permalink_url:
419 entries.append(self.url_result(permalink_url))
420
421 if 'next_href' in response:
422 next_href = response['next_href']
423 if not next_href:
424 break
425 else:
426 next_href = None
92790f4e
JMF
427
428 return {
429 '_type': 'playlist',
430 'id': compat_str(user['id']),
80fb6d4a 431 'title': '%s (%s)' % (user['username'], self._TITLE_MAP[resource]),
20991253
PH
432 'entries': entries,
433 }
434
435
436class SoundcloudPlaylistIE(SoundcloudIE):
46f74bcf 437 _VALID_URL = r'https?://api\.soundcloud\.com/playlists/(?P<id>[0-9]+)(?:/?\?secret_token=(?P<token>[^&]+?))?$'
20991253 438 IE_NAME = 'soundcloud:playlist'
46f74bcf
PH
439 _TESTS = [{
440 'url': 'http://api.soundcloud.com/playlists/4110309',
441 'info_dict': {
442 'id': '4110309',
443 'title': 'TILT Brass - Bowery Poetry Club, August \'03 [Non-Site SCR 02]',
444 'description': 're:.*?TILT Brass - Bowery Poetry Club',
445 },
446 'playlist_count': 6,
447 }]
20991253
PH
448
449 def _real_extract(self, url):
450 mobj = re.match(self._VALID_URL, url)
451 playlist_id = mobj.group('id')
452 base_url = '%s//api.soundcloud.com/playlists/%s.json?' % (self.http_scheme(), playlist_id)
453
2f834e93 454 data_dict = {
20991253 455 'client_id': self._CLIENT_ID,
2f834e93 456 }
457 token = mobj.group('token')
458
459 if token:
460 data_dict['secret_token'] = token
461
462 data = compat_urllib_parse.urlencode(data_dict)
20991253
PH
463 data = self._download_json(
464 base_url + data, playlist_id, 'Downloading playlist')
465
40a2d170 466 entries = [self.url_result(track['permalink_url'], 'Soundcloud') for track in data['tracks']]
20991253
PH
467
468 return {
469 '_type': 'playlist',
470 'id': playlist_id,
471 'title': data.get('title'),
472 'description': data.get('description'),
473 'entries': entries,
92790f4e 474 }
2abf7cab 475
476
477class SoundcloudSearchIE(SearchInfoExtractor, SoundcloudIE):
478 IE_NAME = 'soundcloud:search'
479 IE_DESC = 'Soundcloud search'
480 _MAX_RESULTS = 200
481 _TESTS = [{
482 'url': 'scsearch15:post-avant jazzcore',
483 'info_dict': {
484 'title': 'post-avant jazzcore',
485 },
486 'playlist_count': 15,
487 }]
488
489 _SEARCH_KEY = 'scsearch'
490 _RESULTS_PER_PAGE = 50
c30943b1 491 _API_V2_BASE = 'https://api-v2.soundcloud.com'
2abf7cab 492
493 def _get_collection(self, endpoint, collection_id, **query):
2abf7cab 494 query['limit'] = self._RESULTS_PER_PAGE
495 query['client_id'] = self._CLIENT_ID
496 query['linked_partitioning'] = '1'
497
2abf7cab 498 total_results = self._MAX_RESULTS
499 collected_results = 0
500
501 next_url = None
502
503 for i in itertools.count():
2abf7cab 504 if not next_url:
505 query['offset'] = i * self._RESULTS_PER_PAGE
506 data = compat_urllib_parse.urlencode(query)
c30943b1 507 next_url = '{0}{1}?{2}'.format(self._API_V2_BASE, endpoint, data)
2abf7cab 508
509 response = self._download_json(next_url,
510 video_id=collection_id,
511 note='Downloading page {0}'.format(i+1),
512 errnote='Unable to download API page')
513
514 total_results = int(response.get(
c30943b1 515 'total_results', total_results))
2abf7cab 516
517 collection = response['collection']
518 collected_results += len(collection)
519
520 for item in filter(bool, collection):
521 yield item
522
523 if collected_results >= total_results or not collection:
524 break
525
c30943b1 526 next_url = response.get('next_href', None)
2abf7cab 527
528 def _get_n_results(self, query, n):
2abf7cab 529 tracks = self._get_collection('/search/tracks',
c30943b1 530 collection_id='Query "{0}"'.format(query),
2abf7cab 531 q=query.encode('utf-8'))
532
6ea7190a 533 results = [self.url_result(url=track['uri'])
534 for track in itertools.islice(tracks, n)]
2abf7cab 535
536 if not results:
537 raise ExtractorError(
417b4536 538 'Soundcloud said: No track results', expected=True)
2abf7cab 539
417b4536 540 return self.playlist_result(results, playlist_title=query)
2abf7cab 541