Improve URL extraction
[youtube-dl] / youtube_dl / extractor / bandcamp.py
1 from __future__ import unicode_literals
2
3 import json
4 import random
5 import re
6 import time
7
8 from .common import InfoExtractor
9 from ..compat import (
10     compat_str,
11     compat_urlparse,
12 )
13 from ..utils import (
14     ExtractorError,
15     float_or_none,
16     int_or_none,
17     KNOWN_EXTENSIONS,
18     parse_filesize,
19     unescapeHTML,
20     update_url_query,
21     unified_strdate,
22     url_or_none,
23 )
24
25
26 class BandcampIE(InfoExtractor):
27     _VALID_URL = r'https?://.*?\.bandcamp\.com/track/(?P<title>[^/?#&]+)'
28     _TESTS = [{
29         'url': 'http://youtube-dl.bandcamp.com/track/youtube-dl-test-song',
30         'md5': 'c557841d5e50261777a6585648adf439',
31         'info_dict': {
32             'id': '1812978515',
33             'ext': 'mp3',
34             'title': "youtube-dl  \"'/\\\u00e4\u21ad - youtube-dl test song \"'/\\\u00e4\u21ad",
35             'duration': 9.8485,
36         },
37         '_skip': 'There is a limit of 200 free downloads / month for the test song'
38     }, {
39         'url': 'http://benprunty.bandcamp.com/track/lanius-battle',
40         'md5': '0369ace6b939f0927e62c67a1a8d9fa7',
41         'info_dict': {
42             'id': '2650410135',
43             'ext': 'aiff',
44             'title': 'Ben Prunty - Lanius (Battle)',
45             'uploader': 'Ben Prunty',
46         },
47     }]
48
49     def _real_extract(self, url):
50         mobj = re.match(self._VALID_URL, url)
51         title = mobj.group('title')
52         webpage = self._download_webpage(url, title)
53         thumbnail = self._html_search_meta('og:image', webpage, default=None)
54         m_download = re.search(r'freeDownloadPage: "(.*?)"', webpage)
55         if not m_download:
56             m_trackinfo = re.search(r'trackinfo: (.+),\s*?\n', webpage)
57             if m_trackinfo:
58                 json_code = m_trackinfo.group(1)
59                 data = json.loads(json_code)[0]
60                 track_id = compat_str(data['id'])
61
62                 if not data.get('file'):
63                     raise ExtractorError('Not streamable', video_id=track_id, expected=True)
64
65                 formats = []
66                 for format_id, format_url in data['file'].items():
67                     ext, abr_str = format_id.split('-', 1)
68                     formats.append({
69                         'format_id': format_id,
70                         'url': self._proto_relative_url(format_url, 'http:'),
71                         'ext': ext,
72                         'vcodec': 'none',
73                         'acodec': ext,
74                         'abr': int_or_none(abr_str),
75                     })
76
77                 self._sort_formats(formats)
78
79                 return {
80                     'id': track_id,
81                     'title': data['title'],
82                     'thumbnail': thumbnail,
83                     'formats': formats,
84                     'duration': float_or_none(data.get('duration')),
85                 }
86             else:
87                 raise ExtractorError('No free songs found')
88
89         download_link = m_download.group(1)
90         video_id = self._search_regex(
91             r'(?ms)var TralbumData = .*?[{,]\s*id: (?P<id>\d+),?$',
92             webpage, 'video id')
93
94         download_webpage = self._download_webpage(
95             download_link, video_id, 'Downloading free downloads page')
96
97         blob = self._parse_json(
98             self._search_regex(
99                 r'data-blob=(["\'])(?P<blob>{.+?})\1', download_webpage,
100                 'blob', group='blob'),
101             video_id, transform_source=unescapeHTML)
102
103         info = blob['digital_items'][0]
104
105         downloads = info['downloads']
106         track = info['title']
107
108         artist = info.get('artist')
109         title = '%s - %s' % (artist, track) if artist else track
110
111         download_formats = {}
112         for f in blob['download_formats']:
113             name, ext = f.get('name'), f.get('file_extension')
114             if all(isinstance(x, compat_str) for x in (name, ext)):
115                 download_formats[name] = ext.strip('.')
116
117         formats = []
118         for format_id, f in downloads.items():
119             format_url = f.get('url')
120             if not format_url:
121                 continue
122             # Stat URL generation algorithm is reverse engineered from
123             # download_*_bundle_*.js
124             stat_url = update_url_query(
125                 format_url.replace('/download/', '/statdownload/'), {
126                     '.rand': int(time.time() * 1000 * random.random()),
127                 })
128             format_id = f.get('encoding_name') or format_id
129             stat = self._download_json(
130                 stat_url, video_id, 'Downloading %s JSON' % format_id,
131                 transform_source=lambda s: s[s.index('{'):s.rindex('}') + 1],
132                 fatal=False)
133             if not stat:
134                 continue
135             retry_url = url_or_none(stat.get('retry_url'))
136             if not retry_url:
137                 continue
138             formats.append({
139                 'url': self._proto_relative_url(retry_url, 'http:'),
140                 'ext': download_formats.get(format_id),
141                 'format_id': format_id,
142                 'format_note': f.get('description'),
143                 'filesize': parse_filesize(f.get('size_mb')),
144                 'vcodec': 'none',
145             })
146         self._sort_formats(formats)
147
148         return {
149             'id': video_id,
150             'title': title,
151             'thumbnail': info.get('thumb_url') or thumbnail,
152             'uploader': info.get('artist'),
153             'artist': artist,
154             'track': track,
155             'formats': formats,
156         }
157
158
159 class BandcampAlbumIE(InfoExtractor):
160     IE_NAME = 'Bandcamp:album'
161     _VALID_URL = r'https?://(?:(?P<subdomain>[^.]+)\.)?bandcamp\.com(?:/album/(?P<album_id>[^/?#&]+))?'
162
163     _TESTS = [{
164         'url': 'http://blazo.bandcamp.com/album/jazz-format-mixtape-vol-1',
165         'playlist': [
166             {
167                 'md5': '39bc1eded3476e927c724321ddf116cf',
168                 'info_dict': {
169                     'id': '1353101989',
170                     'ext': 'mp3',
171                     'title': 'Intro',
172                 }
173             },
174             {
175                 'md5': '1a2c32e2691474643e912cc6cd4bffaa',
176                 'info_dict': {
177                     'id': '38097443',
178                     'ext': 'mp3',
179                     'title': 'Kero One - Keep It Alive (Blazo remix)',
180                 }
181             },
182         ],
183         'info_dict': {
184             'title': 'Jazz Format Mixtape vol.1',
185             'id': 'jazz-format-mixtape-vol-1',
186             'uploader_id': 'blazo',
187         },
188         'params': {
189             'playlistend': 2
190         },
191         'skip': 'Bandcamp imposes download limits.'
192     }, {
193         'url': 'http://nightbringer.bandcamp.com/album/hierophany-of-the-open-grave',
194         'info_dict': {
195             'title': 'Hierophany of the Open Grave',
196             'uploader_id': 'nightbringer',
197             'id': 'hierophany-of-the-open-grave',
198         },
199         'playlist_mincount': 9,
200     }, {
201         'url': 'http://dotscale.bandcamp.com',
202         'info_dict': {
203             'title': 'Loom',
204             'id': 'dotscale',
205             'uploader_id': 'dotscale',
206         },
207         'playlist_mincount': 7,
208     }, {
209         # with escaped quote in title
210         'url': 'https://jstrecords.bandcamp.com/album/entropy-ep',
211         'info_dict': {
212             'title': '"Entropy" EP',
213             'uploader_id': 'jstrecords',
214             'id': 'entropy-ep',
215         },
216         'playlist_mincount': 3,
217     }, {
218         # not all tracks have songs
219         'url': 'https://insulters.bandcamp.com/album/we-are-the-plague',
220         'info_dict': {
221             'id': 'we-are-the-plague',
222             'title': 'WE ARE THE PLAGUE',
223             'uploader_id': 'insulters',
224         },
225         'playlist_count': 2,
226     }]
227
228     @classmethod
229     def suitable(cls, url):
230         return (False
231                 if BandcampWeeklyIE.suitable(url) or BandcampIE.suitable(url)
232                 else super(BandcampAlbumIE, cls).suitable(url))
233
234     def _real_extract(self, url):
235         mobj = re.match(self._VALID_URL, url)
236         uploader_id = mobj.group('subdomain')
237         album_id = mobj.group('album_id')
238         playlist_id = album_id or uploader_id
239         webpage = self._download_webpage(url, playlist_id)
240         track_elements = re.findall(
241             r'(?s)<div[^>]*>(.*?<a[^>]+href="([^"]+?)"[^>]+itemprop="url"[^>]*>.*?)</div>', webpage)
242         if not track_elements:
243             raise ExtractorError('The page doesn\'t contain any tracks')
244         # Only tracks with duration info have songs
245         entries = [
246             self.url_result(
247                 compat_urlparse.urljoin(url, t_path),
248                 ie=BandcampIE.ie_key(),
249                 video_title=self._search_regex(
250                     r'<span\b[^>]+\bitemprop=["\']name["\'][^>]*>([^<]+)',
251                     elem_content, 'track title', fatal=False))
252             for elem_content, t_path in track_elements
253             if self._html_search_meta('duration', elem_content, default=None)]
254
255         title = self._html_search_regex(
256             r'album_title\s*:\s*"((?:\\.|[^"\\])+?)"',
257             webpage, 'title', fatal=False)
258         if title:
259             title = title.replace(r'\"', '"')
260         return {
261             '_type': 'playlist',
262             'uploader_id': uploader_id,
263             'id': playlist_id,
264             'title': title,
265             'entries': entries,
266         }
267
268
269 class BandcampWeeklyIE(InfoExtractor):
270     IE_NAME = 'Bandcamp:weekly'
271     _VALID_URL = r'https?://(?:www\.)?bandcamp\.com/?\?(?:.*?&)?show=(?P<id>\d+)'
272     _TESTS = [{
273         'url': 'https://bandcamp.com/?show=224',
274         'md5': 'b00df799c733cf7e0c567ed187dea0fd',
275         'info_dict': {
276             'id': '224',
277             'ext': 'opus',
278             'title': 'BC Weekly April 4th 2017 - Magic Moments',
279             'description': 'md5:5d48150916e8e02d030623a48512c874',
280             'duration': 5829.77,
281             'release_date': '20170404',
282             'series': 'Bandcamp Weekly',
283             'episode': 'Magic Moments',
284             'episode_number': 208,
285             'episode_id': '224',
286         }
287     }, {
288         'url': 'https://bandcamp.com/?blah/blah@&show=228',
289         'only_matching': True
290     }]
291
292     def _real_extract(self, url):
293         video_id = self._match_id(url)
294         webpage = self._download_webpage(url, video_id)
295
296         blob = self._parse_json(
297             self._search_regex(
298                 r'data-blob=(["\'])(?P<blob>{.+?})\1', webpage,
299                 'blob', group='blob'),
300             video_id, transform_source=unescapeHTML)
301
302         show = blob['bcw_show']
303
304         # This is desired because any invalid show id redirects to `bandcamp.com`
305         # which happens to expose the latest Bandcamp Weekly episode.
306         show_id = int_or_none(show.get('show_id')) or int_or_none(video_id)
307
308         formats = []
309         for format_id, format_url in show['audio_stream'].items():
310             if not url_or_none(format_url):
311                 continue
312             for known_ext in KNOWN_EXTENSIONS:
313                 if known_ext in format_id:
314                     ext = known_ext
315                     break
316             else:
317                 ext = None
318             formats.append({
319                 'format_id': format_id,
320                 'url': format_url,
321                 'ext': ext,
322                 'vcodec': 'none',
323             })
324         self._sort_formats(formats)
325
326         title = show.get('audio_title') or 'Bandcamp Weekly'
327         subtitle = show.get('subtitle')
328         if subtitle:
329             title += ' - %s' % subtitle
330
331         episode_number = None
332         seq = blob.get('bcw_seq')
333
334         if seq and isinstance(seq, list):
335             try:
336                 episode_number = next(
337                     int_or_none(e.get('episode_number'))
338                     for e in seq
339                     if isinstance(e, dict) and int_or_none(e.get('id')) == show_id)
340             except StopIteration:
341                 pass
342
343         return {
344             'id': video_id,
345             'title': title,
346             'description': show.get('desc') or show.get('short_desc'),
347             'duration': float_or_none(show.get('audio_duration')),
348             'is_live': False,
349             'release_date': unified_strdate(show.get('published_date')),
350             'series': 'Bandcamp Weekly',
351             'episode': show.get('subtitle'),
352             'episode_number': episode_number,
353             'episode_id': compat_str(video_id),
354             'formats': formats
355         }