From d77c3dfd027e9af4d44fc7109fac0012451268c2 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 25 Mar 2012 03:07:37 +0200 Subject: [PATCH] Split code as a package, compiled into an executable zip --- Makefile | 5 +- youtube-dl | Bin 165784 -> 41021 bytes youtube_dl/FileDownloader.py | 681 ++++++ youtube_dl/InfoExtractors.py | 3076 ++++++++++++++++++++++++ youtube_dl/PostProcessing.py | 185 ++ youtube_dl/Utils.py | 375 +++ youtube_dl/__init__.py | 4288 +--------------------------------- youtube_dl/__main__.py | 7 + 8 files changed, 4333 insertions(+), 4284 deletions(-) create mode 100644 youtube_dl/FileDownloader.py create mode 100644 youtube_dl/InfoExtractors.py create mode 100644 youtube_dl/PostProcessing.py create mode 100644 youtube_dl/Utils.py mode change 100755 => 100644 youtube_dl/__init__.py create mode 100755 youtube_dl/__main__.py diff --git a/Makefile b/Makefile index b1a41079a7..884aeb3268 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ update-readme: echo "$${footer}" >> README.md compile: - cp youtube_dl/__init__.py youtube-dl + zip --junk-paths youtube-dl youtube_dl/*.py + echo '#!/usr/bin/env python' > youtube-dl + cat youtube-dl.zip >> youtube-dl + rm youtube-dl.zip .PHONY: default compile update update-latest update-readme diff --git a/youtube-dl b/youtube-dl index 8d0d1cc3381afab236486af52f9712c110cfa311..d088bd9532507833210d3d04f20f7216fbf62b4b 100755 GIT binary patch literal 41021 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

GoDd;yc4;A60epLQemD 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 99: + return '--:--' + return '%02d:%02d' % (eta_mins, eta_secs) + + @staticmethod + def calc_speed(start, now, bytes): + dif = now - start + if bytes == 0 or dif < 0.001: # One millisecond + return '%10s' % '---b/s' + return '%10s' % ('%s/s' % FileDownloader.format_bytes(float(bytes) / dif)) + + @staticmethod + def best_block_size(elapsed_time, bytes): + new_min = max(bytes / 2.0, 1.0) + new_max = min(max(bytes * 2.0, 1.0), 4194304) # Do not surpass 4 MB + if elapsed_time < 0.001: + return long(new_max) + rate = bytes / elapsed_time + if rate > new_max: + return long(new_max) + if rate < new_min: + return long(new_min) + return long(rate) + + @staticmethod + def parse_bytes(bytestr): + """Parse a string indicating a byte quantity into a long integer.""" + matchobj = re.match(r'(?i)^(\d+(?:\.\d+)?)([kMGTPEZY]?)$', bytestr) + if matchobj is None: + return None + number = float(matchobj.group(1)) + multiplier = 1024.0 ** 'bkmgtpezy'.index(matchobj.group(2).lower()) + return long(round(number * multiplier)) + + def add_info_extractor(self, ie): + """Add an InfoExtractor object to the end of the list.""" + self._ies.append(ie) + ie.set_downloader(self) + + def add_post_processor(self, pp): + """Add a PostProcessor object to the end of the chain.""" + self._pps.append(pp) + pp.set_downloader(self) + + def to_screen(self, message, skip_eol=False): + """Print message to stdout if not in quiet mode.""" + assert type(message) == type(u'') + if not self.params.get('quiet', False): + terminator = [u'\n', u''][skip_eol] + output = message + terminator + + if 'b' not in self._screen_file.mode or sys.version_info[0] < 3: # Python 2 lies about the mode of sys.stdout/sys.stderr + output = output.encode(preferredencoding(), 'ignore') + self._screen_file.write(output) + self._screen_file.flush() + + def to_stderr(self, message): + """Print message to stderr.""" + print >>sys.stderr, message.encode(preferredencoding()) + + def to_cons_title(self, message): + """Set console/terminal window title to message.""" + if not self.params.get('consoletitle', False): + return + if os.name == 'nt' and ctypes.windll.kernel32.GetConsoleWindow(): + # c_wchar_p() might not be necessary if `message` is + # already of type unicode() + ctypes.windll.kernel32.SetConsoleTitleW(ctypes.c_wchar_p(message)) + elif 'TERM' in os.environ: + sys.stderr.write('\033]0;%s\007' % message.encode(preferredencoding())) + + def fixed_template(self): + """Checks if the output template is fixed.""" + return (re.search(ur'(?u)%\(.+?\)s', self.params['outtmpl']) is None) + + def trouble(self, message=None): + """Determine action to take when a download problem appears. + + Depending on if the downloader has been configured to ignore + download errors or not, this method may throw an exception or + not when errors are found, after printing the message. + """ + if message is not None: + self.to_stderr(message) + if not self.params.get('ignoreerrors', False): + raise DownloadError(message) + self._download_retcode = 1 + + def slow_down(self, start_time, byte_counter): + """Sleep if the download speed is over the rate limit.""" + rate_limit = self.params.get('ratelimit', None) + if rate_limit is None or byte_counter == 0: + return + now = time.time() + elapsed = now - start_time + if elapsed <= 0.0: + return + speed = float(byte_counter) / elapsed + if speed > rate_limit: + time.sleep((byte_counter - rate_limit * (now - start_time)) / rate_limit) + + def temp_name(self, filename): + """Returns a temporary filename for the given filename.""" + if self.params.get('nopart', False) or filename == u'-' or \ + (os.path.exists(encodeFilename(filename)) and not os.path.isfile(encodeFilename(filename))): + return filename + return filename + u'.part' + + def undo_temp_name(self, filename): + if filename.endswith(u'.part'): + return filename[:-len(u'.part')] + return filename + + def try_rename(self, old_filename, new_filename): + try: + if old_filename == new_filename: + return + os.rename(encodeFilename(old_filename), encodeFilename(new_filename)) + except (IOError, OSError), err: + self.trouble(u'ERROR: unable to rename file') + + def try_utime(self, filename, last_modified_hdr): + """Try to set the last-modified time of the given file.""" + if last_modified_hdr is None: + return + if not os.path.isfile(encodeFilename(filename)): + return + timestr = last_modified_hdr + if timestr is None: + return + filetime = timeconvert(timestr) + if filetime is None: + return filetime + try: + os.utime(filename, (time.time(), filetime)) + except: + pass + return filetime + + def report_writedescription(self, descfn): + """ Report that the description file is being written """ + self.to_screen(u'[info] Writing video description to: ' + descfn) + + def report_writesubtitles(self, srtfn): + """ Report that the subtitles file is being written """ + self.to_screen(u'[info] Writing video subtitles to: ' + srtfn) + + def report_writeinfojson(self, infofn): + """ Report that the metadata file has been written """ + self.to_screen(u'[info] Video description metadata as JSON to: ' + infofn) + + def report_destination(self, filename): + """Report destination filename.""" + self.to_screen(u'[download] Destination: ' + filename) + + def report_progress(self, percent_str, data_len_str, speed_str, eta_str): + """Report download progress.""" + if self.params.get('noprogress', False): + return + self.to_screen(u'\r[download] %s of %s at %s ETA %s' % + (percent_str, data_len_str, speed_str, eta_str), skip_eol=True) + self.to_cons_title(u'youtube-dl - %s of %s at %s ETA %s' % + (percent_str.strip(), data_len_str.strip(), speed_str.strip(), eta_str.strip())) + + def report_resuming_byte(self, resume_len): + """Report attempt to resume at given byte.""" + self.to_screen(u'[download] Resuming download at byte %s' % resume_len) + + def report_retry(self, count, retries): + """Report retry in case of HTTP error 5xx""" + self.to_screen(u'[download] Got server HTTP error. Retrying (attempt %d of %d)...' % (count, retries)) + + def report_file_already_downloaded(self, file_name): + """Report file has already been fully downloaded.""" + try: + self.to_screen(u'[download] %s has already been downloaded' % file_name) + except (UnicodeEncodeError), err: + self.to_screen(u'[download] The file has already been downloaded') + + def report_unable_to_resume(self): + """Report it was impossible to resume download.""" + self.to_screen(u'[download] Unable to resume') + + def report_finish(self): + """Report download finished.""" + if self.params.get('noprogress', False): + self.to_screen(u'[download] Download completed') + else: + self.to_screen(u'') + + def increment_downloads(self): + """Increment the ordinal that assigns a number to each file.""" + self._num_downloads += 1 + + def prepare_filename(self, info_dict): + """Generate the output filename.""" + try: + template_dict = dict(info_dict) + template_dict['epoch'] = unicode(long(time.time())) + template_dict['autonumber'] = unicode('%05d' % self._num_downloads) + filename = self.params['outtmpl'] % template_dict + return filename + except (ValueError, KeyError), err: + self.trouble(u'ERROR: invalid system charset or erroneous output template') + return None + + def _match_entry(self, info_dict): + """ Returns None iff the file should be downloaded """ + + title = info_dict['title'] + matchtitle = self.params.get('matchtitle', False) + if matchtitle and not re.search(matchtitle, title, re.IGNORECASE): + return u'[download] "' + title + '" title did not match pattern "' + matchtitle + '"' + rejecttitle = self.params.get('rejecttitle', False) + if rejecttitle and re.search(rejecttitle, title, re.IGNORECASE): + return u'"' + title + '" title matched reject pattern "' + rejecttitle + '"' + return None + + def process_info(self, info_dict): + """Process a single dictionary returned by an InfoExtractor.""" + + reason = self._match_entry(info_dict) + if reason is not None: + self.to_screen(u'[download] ' + reason) + return + + max_downloads = self.params.get('max_downloads') + if max_downloads is not None: + if self._num_downloads > int(max_downloads): + raise MaxDownloadsReached() + + filename = self.prepare_filename(info_dict) + + # Forced printings + if self.params.get('forcetitle', False): + print info_dict['title'].encode(preferredencoding(), 'xmlcharrefreplace') + if self.params.get('forceurl', False): + print info_dict['url'].encode(preferredencoding(), 'xmlcharrefreplace') + if self.params.get('forcethumbnail', False) and 'thumbnail' in info_dict: + print info_dict['thumbnail'].encode(preferredencoding(), 'xmlcharrefreplace') + if self.params.get('forcedescription', False) and 'description' in info_dict: + print info_dict['description'].encode(preferredencoding(), 'xmlcharrefreplace') + if self.params.get('forcefilename', False) and filename is not None: + print filename.encode(preferredencoding(), 'xmlcharrefreplace') + if self.params.get('forceformat', False): + print info_dict['format'].encode(preferredencoding(), 'xmlcharrefreplace') + + # Do nothing else if in simulate mode + if self.params.get('simulate', False): + return + + if filename is None: + return + + try: + dn = os.path.dirname(encodeFilename(filename)) + if dn != '' and not os.path.exists(dn): # dn is already encoded + os.makedirs(dn) + except (OSError, IOError), err: + self.trouble(u'ERROR: unable to create directory ' + unicode(err)) + return + + if self.params.get('writedescription', False): + try: + descfn = filename + u'.description' + self.report_writedescription(descfn) + descfile = open(encodeFilename(descfn), 'wb') + try: + descfile.write(info_dict['description'].encode('utf-8')) + finally: + descfile.close() + except (OSError, IOError): + self.trouble(u'ERROR: Cannot write description file ' + descfn) + return + + if self.params.get('writesubtitles', False) and 'subtitles' in info_dict and info_dict['subtitles']: + # subtitles download errors are already managed as troubles in relevant IE + # that way it will silently go on when used with unsupporting IE + try: + srtfn = filename.rsplit('.', 1)[0] + u'.srt' + self.report_writesubtitles(srtfn) + srtfile = open(encodeFilename(srtfn), 'wb') + try: + srtfile.write(info_dict['subtitles'].encode('utf-8')) + finally: + srtfile.close() + except (OSError, IOError): + self.trouble(u'ERROR: Cannot write subtitles file ' + descfn) + return + + if self.params.get('writeinfojson', False): + infofn = filename + u'.info.json' + self.report_writeinfojson(infofn) + try: + json.dump + except (NameError,AttributeError): + self.trouble(u'ERROR: No JSON encoder found. Update to Python 2.6+, setup a json module, or leave out --write-info-json.') + return + try: + infof = open(encodeFilename(infofn), 'wb') + try: + json_info_dict = dict((k,v) for k,v in info_dict.iteritems() if not k in ('urlhandle',)) + json.dump(json_info_dict, infof) + finally: + infof.close() + except (OSError, IOError): + self.trouble(u'ERROR: Cannot write metadata to JSON file ' + infofn) + return + + if not self.params.get('skip_download', False): + if self.params.get('nooverwrites', False) and os.path.exists(encodeFilename(filename)): + success = True + else: + try: + success = self._do_download(filename, info_dict) + except (OSError, IOError), err: + raise UnavailableVideoError + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self.trouble(u'ERROR: unable to download video data: %s' % str(err)) + return + except (ContentTooShortError, ), err: + self.trouble(u'ERROR: content too short (expected %s bytes and served %s)' % (err.expected, err.downloaded)) + return + + if success: + try: + self.post_process(filename, info_dict) + except (PostProcessingError), err: + self.trouble(u'ERROR: postprocessing: %s' % str(err)) + return + + def download(self, url_list): + """Download a given list of URLs.""" + if len(url_list) > 1 and self.fixed_template(): + raise SameFileError(self.params['outtmpl']) + + for url in url_list: + suitable_found = False + for ie in self._ies: + # Go to next InfoExtractor if not suitable + if not ie.suitable(url): + continue + + # Suitable InfoExtractor found + suitable_found = True + + # Extract information from URL and process it + ie.extract(url) + + # Suitable InfoExtractor had been found; go to next URL + break + + if not suitable_found: + self.trouble(u'ERROR: no suitable InfoExtractor: %s' % url) + + return self._download_retcode + + def post_process(self, filename, ie_info): + """Run the postprocessing chain on the given file.""" + info = dict(ie_info) + info['filepath'] = filename + for pp in self._pps: + info = pp.run(info) + if info is None: + break + + def _download_with_rtmpdump(self, filename, url, player_url): + self.report_destination(filename) + tmpfilename = self.temp_name(filename) + + # Check for rtmpdump first + try: + subprocess.call(['rtmpdump', '-h'], stdout=(file(os.path.devnull, 'w')), stderr=subprocess.STDOUT) + except (OSError, IOError): + self.trouble(u'ERROR: RTMP download detected but "rtmpdump" could not be run') + return False + + # Download using rtmpdump. rtmpdump returns exit code 2 when + # the connection was interrumpted and resuming appears to be + # possible. This is part of rtmpdump's normal usage, AFAIK. + basic_args = ['rtmpdump', '-q'] + [[], ['-W', player_url]][player_url is not None] + ['-r', url, '-o', tmpfilename] + args = basic_args + [[], ['-e', '-k', '1']][self.params.get('continuedl', False)] + if self.params.get('verbose', False): + try: + import pipes + shell_quote = lambda args: ' '.join(map(pipes.quote, args)) + except ImportError: + shell_quote = repr + self.to_screen(u'[debug] rtmpdump command line: ' + shell_quote(args)) + retval = subprocess.call(args) + while retval == 2 or retval == 1: + prevsize = os.path.getsize(encodeFilename(tmpfilename)) + self.to_screen(u'\r[rtmpdump] %s bytes' % prevsize, skip_eol=True) + time.sleep(5.0) # This seems to be needed + retval = subprocess.call(basic_args + ['-e'] + [[], ['-k', '1']][retval == 1]) + cursize = os.path.getsize(encodeFilename(tmpfilename)) + if prevsize == cursize and retval == 1: + break + # Some rtmp streams seem abort after ~ 99.8%. Don't complain for those + if prevsize == cursize and retval == 2 and cursize > 1024: + self.to_screen(u'\r[rtmpdump] Could not download the whole video. This can happen for some advertisements.') + retval = 0 + break + if retval == 0: + self.to_screen(u'\r[rtmpdump] %s bytes' % os.path.getsize(encodeFilename(tmpfilename))) + self.try_rename(tmpfilename, filename) + return True + else: + self.trouble(u'\nERROR: rtmpdump exited with code %d' % retval) + return False + + def _do_download(self, filename, info_dict): + url = info_dict['url'] + player_url = info_dict.get('player_url', None) + + # Check file already present + if self.params.get('continuedl', False) and os.path.isfile(encodeFilename(filename)) and not self.params.get('nopart', False): + self.report_file_already_downloaded(filename) + return True + + # Attempt to download using rtmpdump + if url.startswith('rtmp'): + return self._download_with_rtmpdump(filename, url, player_url) + + tmpfilename = self.temp_name(filename) + stream = None + + # Do not include the Accept-Encoding header + headers = {'Youtubedl-no-compression': 'True'} + basic_request = urllib2.Request(url, None, headers) + request = urllib2.Request(url, None, headers) + + # Establish possible resume length + if os.path.isfile(encodeFilename(tmpfilename)): + resume_len = os.path.getsize(encodeFilename(tmpfilename)) + else: + resume_len = 0 + + open_mode = 'wb' + if resume_len != 0: + if self.params.get('continuedl', False): + self.report_resuming_byte(resume_len) + request.add_header('Range','bytes=%d-' % resume_len) + open_mode = 'ab' + else: + resume_len = 0 + + count = 0 + retries = self.params.get('retries', 0) + while count <= retries: + # Establish connection + try: + if count == 0 and 'urlhandle' in info_dict: + data = info_dict['urlhandle'] + data = urllib2.urlopen(request) + break + except (urllib2.HTTPError, ), err: + if (err.code < 500 or err.code >= 600) and err.code != 416: + # Unexpected HTTP error + raise + elif err.code == 416: + # Unable to resume (requested range not satisfiable) + try: + # Open the connection again without the range header + data = urllib2.urlopen(basic_request) + content_length = data.info()['Content-Length'] + except (urllib2.HTTPError, ), err: + if err.code < 500 or err.code >= 600: + raise + else: + # Examine the reported length + if (content_length is not None and + (resume_len - 100 < long(content_length) < resume_len + 100)): + # The file had already been fully downloaded. + # Explanation to the above condition: in issue #175 it was revealed that + # YouTube sometimes adds or removes a few bytes from the end of the file, + # changing the file size slightly and causing problems for some users. So + # I decided to implement a suggested change and consider the file + # completely downloaded if the file size differs less than 100 bytes from + # the one in the hard drive. + self.report_file_already_downloaded(filename) + self.try_rename(tmpfilename, filename) + return True + else: + # The length does not match, we start the download over + self.report_unable_to_resume() + open_mode = 'wb' + break + # Retry + count += 1 + if count <= retries: + self.report_retry(count, retries) + + if count > retries: + self.trouble(u'ERROR: giving up after %s retries' % retries) + return False + + data_len = data.info().get('Content-length', None) + if data_len is not None: + data_len = long(data_len) + resume_len + data_len_str = self.format_bytes(data_len) + byte_counter = 0 + resume_len + block_size = 1024 + start = time.time() + while True: + # Download and write + before = time.time() + data_block = data.read(block_size) + after = time.time() + if len(data_block) == 0: + break + byte_counter += len(data_block) + + # Open file just in time + if stream is None: + try: + (stream, tmpfilename) = sanitize_open(tmpfilename, open_mode) + assert stream is not None + filename = self.undo_temp_name(tmpfilename) + self.report_destination(filename) + except (OSError, IOError), err: + self.trouble(u'ERROR: unable to open for writing: %s' % str(err)) + return False + try: + stream.write(data_block) + except (IOError, OSError), err: + self.trouble(u'\nERROR: unable to write data: %s' % str(err)) + return False + block_size = self.best_block_size(after - before, len(data_block)) + + # Progress message + speed_str = self.calc_speed(start, time.time(), byte_counter - resume_len) + if data_len is None: + self.report_progress('Unknown %', data_len_str, speed_str, 'Unknown ETA') + else: + percent_str = self.calc_percent(byte_counter, data_len) + eta_str = self.calc_eta(start, time.time(), data_len - resume_len, byte_counter - resume_len) + self.report_progress(percent_str, data_len_str, speed_str, eta_str) + + # Apply rate limit + self.slow_down(start, byte_counter - resume_len) + + if stream is None: + self.trouble(u'\nERROR: Did not get any data blocks') + return False + stream.close() + self.report_finish() + if data_len is not None and byte_counter != data_len: + raise ContentTooShortError(byte_counter, long(data_len)) + self.try_rename(tmpfilename, filename) + + # Update file modification time + if self.params.get('updatetime', True): + info_dict['filetime'] = self.try_utime(filename, data.info().get('last-modified', None)) + + return True diff --git a/youtube_dl/InfoExtractors.py b/youtube_dl/InfoExtractors.py new file mode 100644 index 0000000000..c9c563599e --- /dev/null +++ b/youtube_dl/InfoExtractors.py @@ -0,0 +1,3076 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import datetime +import HTMLParser +import httplib +import netrc +import os +import re +import socket +import time +import urllib +import urllib2 +import email.utils + +try: + import cStringIO as StringIO +except ImportError: + import StringIO + +# parse_qs was moved from the cgi module to the urlparse module recently. +try: + from urlparse import parse_qs +except ImportError: + from cgi import parse_qs + +try: + import lxml.etree +except ImportError: + pass # Handled below + +try: + import xml.etree.ElementTree +except ImportError: # Python<2.5: Not officially supported, but let it slip + warnings.warn('xml.etree.ElementTree support is missing. Consider upgrading to Python >= 2.5 if you get related errors.') + +from Utils import * + + +class InfoExtractor(object): + """Information Extractor class. + + Information extractors are the classes that, given a URL, extract + information from the video (or videos) the URL refers to. This + information includes the real video URL, the video title and simplified + title, author and others. The information is stored in a dictionary + which is then passed to the FileDownloader. The FileDownloader + processes this information possibly downloading the video to the file + system, among other possible outcomes. The dictionaries must include + the following fields: + + id: Video identifier. + url: Final video URL. + uploader: Nickname of the video uploader. + title: Literal title. + stitle: Simplified title. + ext: Video filename extension. + format: Video format. + player_url: SWF Player URL (may be None). + + The following fields are optional. Their primary purpose is to allow + youtube-dl to serve as the backend for a video search function, such + as the one in youtube2mp3. They are only used when their respective + forced printing functions are called: + + thumbnail: Full URL to a video thumbnail image. + description: One-line video description. + + Subclasses of this one should re-define the _real_initialize() and + _real_extract() methods and define a _VALID_URL regexp. + Probably, they should also be added to the list of extractors. + """ + + _ready = False + _downloader = None + + def __init__(self, downloader=None): + """Constructor. Receives an optional downloader.""" + self._ready = False + self.set_downloader(downloader) + + def suitable(self, url): + """Receives a URL and returns True if suitable for this IE.""" + return re.match(self._VALID_URL, url) is not None + + def initialize(self): + """Initializes an instance (authentication, etc).""" + if not self._ready: + self._real_initialize() + self._ready = True + + def extract(self, url): + """Extracts URL information and returns it in list of dicts.""" + self.initialize() + return self._real_extract(url) + + def set_downloader(self, downloader): + """Sets the downloader for this IE.""" + self._downloader = downloader + + def _real_initialize(self): + """Real initialization process. Redefine in subclasses.""" + pass + + def _real_extract(self, url): + """Real extraction process. Redefine in subclasses.""" + pass + + +class YoutubeIE(InfoExtractor): + """Information extractor for youtube.com.""" + + _VALID_URL = r'^((?:https?://)?(?:youtu\.be/|(?:\w+\.)?youtube(?:-nocookie)?\.com/)(?!view_play_list|my_playlists|artist|playlist)(?:(?:(?:v|embed|e)/)|(?:(?:watch(?:_popup)?(?:\.php)?)?(?:\?|#!?)(?:.+&)?v=))?)?([0-9A-Za-z_-]+)(?(1).+)?$' + _LANG_URL = r'http://www.youtube.com/?hl=en&persist_hl=1&gl=US&persist_gl=1&opt_out_ackd=1' + _LOGIN_URL = 'https://www.youtube.com/signup?next=/&gl=US&hl=en' + _AGE_URL = 'http://www.youtube.com/verify_age?next_url=/&gl=US&hl=en' + _NEXT_URL_RE = r'[\?&]next_url=([^&]+)' + _NETRC_MACHINE = 'youtube' + # Listed in order of quality + _available_formats = ['38', '37', '22', '45', '35', '44', '34', '18', '43', '6', '5', '17', '13'] + _available_formats_prefer_free = ['38', '37', '45', '22', '44', '35', '43', '34', '18', '6', '5', '17', '13'] + _video_extensions = { + '13': '3gp', + '17': 'mp4', + '18': 'mp4', + '22': 'mp4', + '37': 'mp4', + '38': 'video', # You actually don't know if this will be MOV, AVI or whatever + '43': 'webm', + '44': 'webm', + '45': 'webm', + } + _video_dimensions = { + '5': '240x400', + '6': '???', + '13': '???', + '17': '144x176', + '18': '360x640', + '22': '720x1280', + '34': '360x640', + '35': '480x854', + '37': '1080x1920', + '38': '3072x4096', + '43': '360x640', + '44': '480x854', + '45': '720x1280', + } + IE_NAME = u'youtube' + + def report_lang(self): + """Report attempt to set language.""" + self._downloader.to_screen(u'[youtube] Setting language') + + def report_login(self): + """Report attempt to log in.""" + self._downloader.to_screen(u'[youtube] Logging in') + + def report_age_confirmation(self): + """Report attempt to confirm age.""" + self._downloader.to_screen(u'[youtube] Confirming age') + + def report_video_webpage_download(self, video_id): + """Report attempt to download video webpage.""" + self._downloader.to_screen(u'[youtube] %s: Downloading video webpage' % video_id) + + def report_video_info_webpage_download(self, video_id): + """Report attempt to download video info webpage.""" + self._downloader.to_screen(u'[youtube] %s: Downloading video info webpage' % video_id) + + def report_video_subtitles_download(self, video_id): + """Report attempt to download video info webpage.""" + self._downloader.to_screen(u'[youtube] %s: Downloading video subtitles' % video_id) + + def report_information_extraction(self, video_id): + """Report attempt to extract video information.""" + self._downloader.to_screen(u'[youtube] %s: Extracting video information' % video_id) + + def report_unavailable_format(self, video_id, format): + """Report extracted video URL.""" + self._downloader.to_screen(u'[youtube] %s: Format %s not available' % (video_id, format)) + + def report_rtmp_download(self): + """Indicate the download will use the RTMP protocol.""" + self._downloader.to_screen(u'[youtube] RTMP download detected') + + def _closed_captions_xml_to_srt(self, xml_string): + srt = '' + texts = re.findall(r'([^<]+)', xml_string, re.MULTILINE) + # TODO parse xml instead of regex + for n, (start, dur_tag, dur, caption) in enumerate(texts): + if not dur: dur = '4' + start = float(start) + end = start + float(dur) + start = "%02i:%02i:%02i,%03i" %(start/(60*60), start/60%60, start%60, start%1*1000) + end = "%02i:%02i:%02i,%03i" %(end/(60*60), end/60%60, end%60, end%1*1000) + caption = re.sub(ur'(?u)&(.+?);', htmlentity_transform, caption) + caption = re.sub(ur'(?u)&(.+?);', htmlentity_transform, caption) # double cycle, inentional + srt += str(n) + '\n' + srt += start + ' --> ' + end + '\n' + srt += caption + '\n\n' + return srt + + def _print_formats(self, formats): + print 'Available formats:' + for x in formats: + print '%s\t:\t%s\t[%s]' %(x, self._video_extensions.get(x, 'flv'), self._video_dimensions.get(x, '???')) + + def _real_initialize(self): + if self._downloader is None: + return + + username = None + password = None + downloader_params = self._downloader.params + + # Attempt to use provided username and password or .netrc data + if downloader_params.get('username', None) is not None: + username = downloader_params['username'] + password = downloader_params['password'] + elif downloader_params.get('usenetrc', False): + try: + info = netrc.netrc().authenticators(self._NETRC_MACHINE) + if info is not None: + username = info[0] + password = info[2] + else: + raise netrc.NetrcParseError('No authenticators for %s' % self._NETRC_MACHINE) + except (IOError, netrc.NetrcParseError), err: + self._downloader.to_stderr(u'WARNING: parsing .netrc: %s' % str(err)) + return + + # Set language + request = urllib2.Request(self._LANG_URL) + try: + self.report_lang() + urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.to_stderr(u'WARNING: unable to set language: %s' % str(err)) + return + + # No authentication to be performed + if username is None: + return + + # Log in + login_form = { + 'current_form': 'loginForm', + 'next': '/', + 'action_login': 'Log In', + 'username': username, + 'password': password, + } + request = urllib2.Request(self._LOGIN_URL, urllib.urlencode(login_form)) + try: + self.report_login() + login_results = urllib2.urlopen(request).read() + if re.search(r'(?i)]* name="loginForm"', login_results) is not None: + self._downloader.to_stderr(u'WARNING: unable to log in: bad username or password') + return + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.to_stderr(u'WARNING: unable to log in: %s' % str(err)) + return + + # Confirm age + age_form = { + 'next_url': '/', + 'action_confirm': 'Confirm', + } + request = urllib2.Request(self._AGE_URL, urllib.urlencode(age_form)) + try: + self.report_age_confirmation() + age_results = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to confirm age: %s' % str(err)) + return + + def _real_extract(self, url): + # Extract original video URL from URL with redirection, like age verification, using next_url parameter + mobj = re.search(self._NEXT_URL_RE, url) + if mobj: + url = 'http://www.youtube.com/' + urllib.unquote(mobj.group(1)).lstrip('/') + + # Extract video id from URL + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + video_id = mobj.group(2) + + # Get video webpage + self.report_video_webpage_download(video_id) + request = urllib2.Request('http://www.youtube.com/watch?v=%s&gl=US&hl=en&has_verified=1' % video_id) + try: + video_webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) + return + + # Attempt to extract SWF player URL + mobj = re.search(r'swfConfig.*?"(http:\\/\\/.*?watch.*?-.*?\.swf)"', video_webpage) + if mobj is not None: + player_url = re.sub(r'\\(.)', r'\1', mobj.group(1)) + else: + player_url = None + + # Get video info + self.report_video_info_webpage_download(video_id) + for el_type in ['&el=embedded', '&el=detailpage', '&el=vevo', '']: + video_info_url = ('http://www.youtube.com/get_video_info?&video_id=%s%s&ps=default&eurl=&gl=US&hl=en' + % (video_id, el_type)) + request = urllib2.Request(video_info_url) + try: + video_info_webpage = urllib2.urlopen(request).read() + video_info = parse_qs(video_info_webpage) + if 'token' in video_info: + break + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video info webpage: %s' % str(err)) + return + if 'token' not in video_info: + if 'reason' in video_info: + self._downloader.trouble(u'ERROR: YouTube said: %s' % video_info['reason'][0].decode('utf-8')) + else: + self._downloader.trouble(u'ERROR: "token" parameter not in video info for unknown reason') + return + + # Start extracting information + self.report_information_extraction(video_id) + + # uploader + if 'author' not in video_info: + self._downloader.trouble(u'ERROR: unable to extract uploader nickname') + return + video_uploader = urllib.unquote_plus(video_info['author'][0]) + + # title + if 'title' not in video_info: + self._downloader.trouble(u'ERROR: unable to extract video title') + return + video_title = urllib.unquote_plus(video_info['title'][0]) + video_title = video_title.decode('utf-8') + video_title = sanitize_title(video_title) + + # simplified title + simple_title = simplify_title(video_title) + + # thumbnail image + if 'thumbnail_url' not in video_info: + self._downloader.trouble(u'WARNING: unable to extract video thumbnail') + video_thumbnail = '' + else: # don't panic if we can't find it + video_thumbnail = urllib.unquote_plus(video_info['thumbnail_url'][0]) + + # upload date + upload_date = u'NA' + mobj = re.search(r'id="eow-date.*?>(.*?)', video_webpage, re.DOTALL) + if mobj is not None: + upload_date = ' '.join(re.sub(r'[/,-]', r' ', mobj.group(1)).split()) + format_expressions = ['%d %B %Y', '%B %d %Y', '%b %d %Y'] + for expression in format_expressions: + try: + upload_date = datetime.datetime.strptime(upload_date, expression).strftime('%Y%m%d') + except: + pass + + # description + try: + lxml.etree + except NameError: + video_description = u'No description available.' + mobj = re.search(r'', video_webpage) + if mobj is not None: + video_description = mobj.group(1).decode('utf-8') + else: + html_parser = lxml.etree.HTMLParser(encoding='utf-8') + vwebpage_doc = lxml.etree.parse(StringIO.StringIO(video_webpage), html_parser) + video_description = u''.join(vwebpage_doc.xpath('id("eow-description")//text()')) + # TODO use another parser + + # closed captions + video_subtitles = None + if self._downloader.params.get('writesubtitles', False): + self.report_video_subtitles_download(video_id) + request = urllib2.Request('http://video.google.com/timedtext?hl=en&type=list&v=%s' % video_id) + try: + srt_list = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'WARNING: unable to download video subtitles: %s' % str(err)) + else: + srt_lang_list = re.findall(r'lang_code="([\w\-]+)"', srt_list) + if srt_lang_list: + if self._downloader.params.get('subtitleslang', False): + srt_lang = self._downloader.params.get('subtitleslang') + elif 'en' in srt_lang_list: + srt_lang = 'en' + else: + srt_lang = srt_lang_list[0] + if not srt_lang in srt_lang_list: + self._downloader.trouble(u'WARNING: no closed captions found in the specified language') + else: + request = urllib2.Request('http://video.google.com/timedtext?hl=en&lang=%s&v=%s' % (srt_lang, video_id)) + try: + srt_xml = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'WARNING: unable to download video subtitles: %s' % str(err)) + else: + video_subtitles = self._closed_captions_xml_to_srt(srt_xml.decode('utf-8')) + else: + self._downloader.trouble(u'WARNING: video has no closed captions') + + # token + video_token = urllib.unquote_plus(video_info['token'][0]) + + # Decide which formats to download + req_format = self._downloader.params.get('format', None) + + if 'conn' in video_info and video_info['conn'][0].startswith('rtmp'): + self.report_rtmp_download() + video_url_list = [(None, video_info['conn'][0])] + elif 'url_encoded_fmt_stream_map' in video_info and len(video_info['url_encoded_fmt_stream_map']) >= 1: + url_data_strs = video_info['url_encoded_fmt_stream_map'][0].split(',') + url_data = [parse_qs(uds) for uds in url_data_strs] + url_data = filter(lambda ud: 'itag' in ud and 'url' in ud, url_data) + url_map = dict((ud['itag'][0], ud['url'][0]) for ud in url_data) + + format_limit = self._downloader.params.get('format_limit', None) + available_formats = self._available_formats_prefer_free if self._downloader.params.get('prefer_free_formats', False) else self._available_formats + if format_limit is not None and format_limit in available_formats: + format_list = available_formats[available_formats.index(format_limit):] + else: + format_list = available_formats + existing_formats = [x for x in format_list if x in url_map] + if len(existing_formats) == 0: + self._downloader.trouble(u'ERROR: no known formats available for video') + return + if self._downloader.params.get('listformats', None): + self._print_formats(existing_formats) + return + if req_format is None or req_format == 'best': + video_url_list = [(existing_formats[0], url_map[existing_formats[0]])] # Best quality + elif req_format == 'worst': + video_url_list = [(existing_formats[len(existing_formats)-1], url_map[existing_formats[len(existing_formats)-1]])] # worst quality + elif req_format in ('-1', 'all'): + video_url_list = [(f, url_map[f]) for f in existing_formats] # All formats + else: + # Specific formats. We pick the first in a slash-delimeted sequence. + # For example, if '1/2/3/4' is requested and '2' and '4' are available, we pick '2'. + req_formats = req_format.split('/') + video_url_list = None + for rf in req_formats: + if rf in url_map: + video_url_list = [(rf, url_map[rf])] + break + if video_url_list is None: + self._downloader.trouble(u'ERROR: requested format not available') + return + else: + self._downloader.trouble(u'ERROR: no conn or url_encoded_fmt_stream_map information found in video info') + return + + for format_param, video_real_url in video_url_list: + # At this point we have a new video + self._downloader.increment_downloads() + + # Extension + video_extension = self._video_extensions.get(format_param, 'flv') + + try: + # Process video information + self._downloader.process_info({ + 'id': video_id.decode('utf-8'), + 'url': video_real_url.decode('utf-8'), + 'uploader': video_uploader.decode('utf-8'), + 'upload_date': upload_date, + 'title': video_title, + 'stitle': simple_title, + 'ext': video_extension.decode('utf-8'), + 'format': (format_param is None and u'NA' or format_param.decode('utf-8')), + 'thumbnail': video_thumbnail.decode('utf-8'), + 'description': video_description, + 'player_url': player_url, + 'subtitles': video_subtitles + }) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class MetacafeIE(InfoExtractor): + """Information Extractor for metacafe.com.""" + + _VALID_URL = r'(?:http://)?(?:www\.)?metacafe\.com/watch/([^/]+)/([^/]+)/.*' + _DISCLAIMER = 'http://www.metacafe.com/family_filter/' + _FILTER_POST = 'http://www.metacafe.com/f/index.php?inputType=filter&controllerGroup=user' + _youtube_ie = None + IE_NAME = u'metacafe' + + def __init__(self, youtube_ie, downloader=None): + InfoExtractor.__init__(self, downloader) + self._youtube_ie = youtube_ie + + def report_disclaimer(self): + """Report disclaimer retrieval.""" + self._downloader.to_screen(u'[metacafe] Retrieving disclaimer') + + def report_age_confirmation(self): + """Report attempt to confirm age.""" + self._downloader.to_screen(u'[metacafe] Confirming age') + + def report_download_webpage(self, video_id): + """Report webpage download.""" + self._downloader.to_screen(u'[metacafe] %s: Downloading webpage' % video_id) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[metacafe] %s: Extracting information' % video_id) + + def _real_initialize(self): + # Retrieve disclaimer + request = urllib2.Request(self._DISCLAIMER) + try: + self.report_disclaimer() + disclaimer = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to retrieve disclaimer: %s' % str(err)) + return + + # Confirm age + disclaimer_form = { + 'filters': '0', + 'submit': "Continue - I'm over 18", + } + request = urllib2.Request(self._FILTER_POST, urllib.urlencode(disclaimer_form)) + try: + self.report_age_confirmation() + disclaimer = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to confirm age: %s' % str(err)) + return + + def _real_extract(self, url): + # Extract id and simplified title from URL + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + + video_id = mobj.group(1) + + # Check if video comes from YouTube + mobj2 = re.match(r'^yt-(.*)$', video_id) + if mobj2 is not None: + self._youtube_ie.extract('http://www.youtube.com/watch?v=%s' % mobj2.group(1)) + return + + # At this point we have a new video + self._downloader.increment_downloads() + + simple_title = mobj.group(2).decode('utf-8') + + # Retrieve video webpage to extract further information + request = urllib2.Request('http://www.metacafe.com/watch/%s/' % video_id) + try: + self.report_download_webpage(video_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable retrieve video webpage: %s' % str(err)) + return + + # Extract URL, uploader and title from webpage + self.report_extraction(video_id) + mobj = re.search(r'(?m)&mediaURL=([^&]+)', webpage) + if mobj is not None: + mediaURL = urllib.unquote(mobj.group(1)) + video_extension = mediaURL[-3:] + + # Extract gdaKey if available + mobj = re.search(r'(?m)&gdaKey=(.*?)&', webpage) + if mobj is None: + video_url = mediaURL + else: + gdaKey = mobj.group(1) + video_url = '%s?__gda__=%s' % (mediaURL, gdaKey) + else: + mobj = re.search(r' name="flashvars" value="(.*?)"', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract media URL') + return + vardict = parse_qs(mobj.group(1)) + if 'mediaData' not in vardict: + self._downloader.trouble(u'ERROR: unable to extract media URL') + return + mobj = re.search(r'"mediaURL":"(http.*?)","key":"(.*?)"', vardict['mediaData'][0]) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract media URL') + return + mediaURL = mobj.group(1).replace('\\/', '/') + video_extension = mediaURL[-3:] + video_url = '%s?__gda__=%s' % (mediaURL, mobj.group(2)) + + mobj = re.search(r'(?im)(.*) - Video', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract title') + return + video_title = mobj.group(1).decode('utf-8') + video_title = sanitize_title(video_title) + + mobj = re.search(r'(?ms)By:\s*(.+?)<', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract uploader nickname') + return + video_uploader = mobj.group(1) + + try: + # Process video information + self._downloader.process_info({ + 'id': video_id.decode('utf-8'), + 'url': video_url.decode('utf-8'), + 'uploader': video_uploader.decode('utf-8'), + 'upload_date': u'NA', + 'title': video_title, + 'stitle': simple_title, + 'ext': video_extension.decode('utf-8'), + 'format': u'NA', + 'player_url': None, + }) + except UnavailableVideoError: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class DailymotionIE(InfoExtractor): + """Information Extractor for Dailymotion""" + + _VALID_URL = r'(?i)(?:https?://)?(?:www\.)?dailymotion\.[a-z]{2,3}/video/([^_/]+)_([^/]+)' + IE_NAME = u'dailymotion' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_download_webpage(self, video_id): + """Report webpage download.""" + self._downloader.to_screen(u'[dailymotion] %s: Downloading webpage' % video_id) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[dailymotion] %s: Extracting information' % video_id) + + def _real_extract(self, url): + # Extract id and simplified title from URL + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + + # At this point we have a new video + self._downloader.increment_downloads() + video_id = mobj.group(1) + + video_extension = 'flv' + + # Retrieve video webpage to extract further information + request = urllib2.Request(url) + request.add_header('Cookie', 'family_filter=off') + try: + self.report_download_webpage(video_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable retrieve video webpage: %s' % str(err)) + return + + # Extract URL, uploader and title from webpage + self.report_extraction(video_id) + mobj = re.search(r'(?i)addVariable\(\"sequence\"\s*,\s*\"([^\"]+?)\"\)', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract media URL') + return + sequence = urllib.unquote(mobj.group(1)) + mobj = re.search(r',\"sdURL\"\:\"([^\"]+?)\",', sequence) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract media URL') + return + mediaURL = urllib.unquote(mobj.group(1)).replace('\\', '') + + # if needed add http://www.dailymotion.com/ if relative URL + + video_url = mediaURL + + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract title') + return + video_title = unescapeHTML(mobj.group('title').decode('utf-8')) + video_title = sanitize_title(video_title) + simple_title = simplify_title(video_title) + + mobj = re.search(r'(?im)[^<]+?]+?>([^<]+?)', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract uploader nickname') + return + video_uploader = mobj.group(1) + + try: + # Process video information + self._downloader.process_info({ + 'id': video_id.decode('utf-8'), + 'url': video_url.decode('utf-8'), + 'uploader': video_uploader.decode('utf-8'), + 'upload_date': u'NA', + 'title': video_title, + 'stitle': simple_title, + 'ext': video_extension.decode('utf-8'), + 'format': u'NA', + 'player_url': None, + }) + except UnavailableVideoError: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class GoogleIE(InfoExtractor): + """Information extractor for video.google.com.""" + + _VALID_URL = r'(?:http://)?video\.google\.(?:com(?:\.au)?|co\.(?:uk|jp|kr|cr)|ca|de|es|fr|it|nl|pl)/videoplay\?docid=([^\&]+).*' + IE_NAME = u'video.google' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_download_webpage(self, video_id): + """Report webpage download.""" + self._downloader.to_screen(u'[video.google] %s: Downloading webpage' % video_id) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[video.google] %s: Extracting information' % video_id) + + def _real_extract(self, url): + # Extract id from URL + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) + return + + # At this point we have a new video + self._downloader.increment_downloads() + video_id = mobj.group(1) + + video_extension = 'mp4' + + # Retrieve video webpage to extract further information + request = urllib2.Request('http://video.google.com/videoplay?docid=%s&hl=en&oe=utf-8' % video_id) + try: + self.report_download_webpage(video_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) + return + + # Extract URL, uploader, and title from webpage + self.report_extraction(video_id) + mobj = re.search(r"download_url:'([^']+)'", webpage) + if mobj is None: + video_extension = 'flv' + mobj = re.search(r"(?i)videoUrl\\x3d(.+?)\\x26", webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract media URL') + return + mediaURL = urllib.unquote(mobj.group(1)) + mediaURL = mediaURL.replace('\\x3d', '\x3d') + mediaURL = mediaURL.replace('\\x26', '\x26') + + video_url = mediaURL + + mobj = re.search(r'(.*)', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract title') + return + video_title = mobj.group(1).decode('utf-8') + video_title = sanitize_title(video_title) + simple_title = simplify_title(video_title) + + # Extract video description + mobj = re.search(r'([^<]*)', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video description') + return + video_description = mobj.group(1).decode('utf-8') + if not video_description: + video_description = 'No description available.' + + # Extract video thumbnail + if self._downloader.params.get('forcethumbnail', False): + request = urllib2.Request('http://video.google.com/videosearch?q=%s+site:video.google.com&hl=en' % abs(int(video_id))) + try: + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) + return + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video thumbnail') + return + video_thumbnail = mobj.group(1) + else: # we need something to pass to process_info + video_thumbnail = '' + + try: + # Process video information + self._downloader.process_info({ + 'id': video_id.decode('utf-8'), + 'url': video_url.decode('utf-8'), + 'uploader': u'NA', + 'upload_date': u'NA', + 'title': video_title, + 'stitle': simple_title, + 'ext': video_extension.decode('utf-8'), + 'format': u'NA', + 'player_url': None, + }) + except UnavailableVideoError: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class PhotobucketIE(InfoExtractor): + """Information extractor for photobucket.com.""" + + _VALID_URL = r'(?:http://)?(?:[a-z0-9]+\.)?photobucket\.com/.*[\?\&]current=(.*\.flv)' + IE_NAME = u'photobucket' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_download_webpage(self, video_id): + """Report webpage download.""" + self._downloader.to_screen(u'[photobucket] %s: Downloading webpage' % video_id) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[photobucket] %s: Extracting information' % video_id) + + def _real_extract(self, url): + # Extract id from URL + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) + return + + # At this point we have a new video + self._downloader.increment_downloads() + video_id = mobj.group(1) + + video_extension = 'flv' + + # Retrieve video webpage to extract further information + request = urllib2.Request(url) + try: + self.report_download_webpage(video_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) + return + + # Extract URL, uploader, and title from webpage + self.report_extraction(video_id) + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract media URL') + return + mediaURL = urllib.unquote(mobj.group(1)) + + video_url = mediaURL + + mobj = re.search(r'(.*) video by (.*) - Photobucket', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract title') + return + video_title = mobj.group(1).decode('utf-8') + video_title = sanitize_title(video_title) + simple_title = simplify_title(video_title) + + video_uploader = mobj.group(2).decode('utf-8') + + try: + # Process video information + self._downloader.process_info({ + 'id': video_id.decode('utf-8'), + 'url': video_url.decode('utf-8'), + 'uploader': video_uploader, + 'upload_date': u'NA', + 'title': video_title, + 'stitle': simple_title, + 'ext': video_extension.decode('utf-8'), + 'format': u'NA', + 'player_url': None, + }) + except UnavailableVideoError: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class YahooIE(InfoExtractor): + """Information extractor for video.yahoo.com.""" + + # _VALID_URL matches all Yahoo! Video URLs + # _VPAGE_URL matches only the extractable '/watch/' URLs + _VALID_URL = r'(?:http://)?(?:[a-z]+\.)?video\.yahoo\.com/(?:watch|network)/([0-9]+)(?:/|\?v=)([0-9]+)(?:[#\?].*)?' + _VPAGE_URL = r'(?:http://)?video\.yahoo\.com/watch/([0-9]+)/([0-9]+)(?:[#\?].*)?' + IE_NAME = u'video.yahoo' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_download_webpage(self, video_id): + """Report webpage download.""" + self._downloader.to_screen(u'[video.yahoo] %s: Downloading webpage' % video_id) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[video.yahoo] %s: Extracting information' % video_id) + + def _real_extract(self, url, new_video=True): + # Extract ID from URL + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) + return + + # At this point we have a new video + self._downloader.increment_downloads() + video_id = mobj.group(2) + video_extension = 'flv' + + # Rewrite valid but non-extractable URLs as + # extractable English language /watch/ URLs + if re.match(self._VPAGE_URL, url) is None: + request = urllib2.Request(url) + try: + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) + return + + mobj = re.search(r'\("id", "([0-9]+)"\);', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: Unable to extract id field') + return + yahoo_id = mobj.group(1) + + mobj = re.search(r'\("vid", "([0-9]+)"\);', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: Unable to extract vid field') + return + yahoo_vid = mobj.group(1) + + url = 'http://video.yahoo.com/watch/%s/%s' % (yahoo_vid, yahoo_id) + return self._real_extract(url, new_video=False) + + # Retrieve video webpage to extract further information + request = urllib2.Request(url) + try: + self.report_download_webpage(video_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) + return + + # Extract uploader and title from webpage + self.report_extraction(video_id) + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video title') + return + video_title = mobj.group(1).decode('utf-8') + simple_title = simplify_title(video_title) + + mobj = re.search(r'

(.*)

', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video uploader') + return + video_uploader = mobj.group(1).decode('utf-8') + + # Extract video thumbnail + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video thumbnail') + return + video_thumbnail = mobj.group(1).decode('utf-8') + + # Extract video description + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video description') + return + video_description = mobj.group(1).decode('utf-8') + if not video_description: + video_description = 'No description available.' + + # Extract video height and width + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video height') + return + yv_video_height = mobj.group(1) + + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video width') + return + yv_video_width = mobj.group(1) + + # Retrieve video playlist to extract media URL + # I'm not completely sure what all these options are, but we + # seem to need most of them, otherwise the server sends a 401. + yv_lg = 'R0xx6idZnW2zlrKP8xxAIR' # not sure what this represents + yv_bitrate = '700' # according to Wikipedia this is hard-coded + request = urllib2.Request('http://cosmos.bcst.yahoo.com/up/yep/process/getPlaylistFOP.php?node_id=' + video_id + + '&tech=flash&mode=playlist&lg=' + yv_lg + '&bitrate=' + yv_bitrate + '&vidH=' + yv_video_height + + '&vidW=' + yv_video_width + '&swf=as3&rd=video.yahoo.com&tk=null&adsupported=v1,v2,&eventid=1301797') + try: + self.report_download_webpage(video_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) + return + + # Extract media URL from playlist XML + mobj = re.search(r'', webpage, re.MULTILINE) + if mobj is not None: + video_description = mobj.group(1) + else: + html_parser = lxml.etree.HTMLParser() + vwebpage_doc = lxml.etree.parse(StringIO.StringIO(webpage), html_parser) + video_description = u''.join(vwebpage_doc.xpath('id("description")//text()')).strip() + # TODO use another parser + + # Extract upload date + video_upload_date = u'NA' + mobj = re.search(r'', webpage) + if mobj is not None: + video_upload_date = mobj.group(1) + + # Vimeo specific: extract request signature and timestamp + sig = config['request']['signature'] + timestamp = config['request']['timestamp'] + + # Vimeo specific: extract video codec and quality information + # TODO bind to format param + codecs = [('h264', 'mp4'), ('vp8', 'flv'), ('vp6', 'flv')] + for codec in codecs: + if codec[0] in config["video"]["files"]: + video_codec = codec[0] + video_extension = codec[1] + if 'hd' in config["video"]["files"][codec[0]]: quality = 'hd' + else: quality = 'sd' + break + else: + self._downloader.trouble(u'ERROR: no known codec found') + return + + video_url = "http://player.vimeo.com/play_redirect?clip_id=%s&sig=%s&time=%s&quality=%s&codecs=%s&type=moogaloop_local&embed_location=" \ + %(video_id, sig, timestamp, quality, video_codec.upper()) + + try: + # Process video information + self._downloader.process_info({ + 'id': video_id, + 'url': video_url, + 'uploader': video_uploader, + 'upload_date': video_upload_date, + 'title': video_title, + 'stitle': simple_title, + 'ext': video_extension, + 'thumbnail': video_thumbnail, + 'description': video_description, + 'player_url': None, + }) + except UnavailableVideoError: + self._downloader.trouble(u'ERROR: unable to download video') + + +class GenericIE(InfoExtractor): + """Generic last-resort information extractor.""" + + _VALID_URL = r'.*' + IE_NAME = u'generic' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_download_webpage(self, video_id): + """Report webpage download.""" + self._downloader.to_screen(u'WARNING: Falling back on generic information extractor.') + self._downloader.to_screen(u'[generic] %s: Downloading webpage' % video_id) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[generic] %s: Extracting information' % video_id) + + def report_following_redirect(self, new_url): + """Report information extraction.""" + self._downloader.to_screen(u'[redirect] Following redirect to %s' % new_url) + + def _test_redirect(self, url): + """Check if it is a redirect, like url shorteners, in case restart chain.""" + class HeadRequest(urllib2.Request): + def get_method(self): + return "HEAD" + + class HEADRedirectHandler(urllib2.HTTPRedirectHandler): + """ + Subclass the HTTPRedirectHandler to make it use our + HeadRequest also on the redirected URL + """ + def redirect_request(self, req, fp, code, msg, headers, newurl): + if code in (301, 302, 303, 307): + newurl = newurl.replace(' ', '%20') + newheaders = dict((k,v) for k,v in req.headers.items() + if k.lower() not in ("content-length", "content-type")) + return HeadRequest(newurl, + headers=newheaders, + origin_req_host=req.get_origin_req_host(), + unverifiable=True) + else: + raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) + + class HTTPMethodFallback(urllib2.BaseHandler): + """ + Fallback to GET if HEAD is not allowed (405 HTTP error) + """ + def http_error_405(self, req, fp, code, msg, headers): + fp.read() + fp.close() + + newheaders = dict((k,v) for k,v in req.headers.items() + if k.lower() not in ("content-length", "content-type")) + return self.parent.open(urllib2.Request(req.get_full_url(), + headers=newheaders, + origin_req_host=req.get_origin_req_host(), + unverifiable=True)) + + # Build our opener + opener = urllib2.OpenerDirector() + for handler in [urllib2.HTTPHandler, urllib2.HTTPDefaultErrorHandler, + HTTPMethodFallback, HEADRedirectHandler, + urllib2.HTTPErrorProcessor, urllib2.HTTPSHandler]: + opener.add_handler(handler()) + + response = opener.open(HeadRequest(url)) + new_url = response.geturl() + + if url == new_url: return False + + self.report_following_redirect(new_url) + self._downloader.download([new_url]) + return True + + def _real_extract(self, url): + if self._test_redirect(url): return + + # At this point we have a new video + self._downloader.increment_downloads() + + video_id = url.split('/')[-1] + request = urllib2.Request(url) + try: + self.report_download_webpage(video_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) + return + except ValueError, err: + # since this is the last-resort InfoExtractor, if + # this error is thrown, it'll be thrown here + self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) + return + + self.report_extraction(video_id) + # Start with something easy: JW Player in SWFObject + mobj = re.search(r'flashvars: [\'"](?:.*&)?file=(http[^\'"&]*)', webpage) + if mobj is None: + # Broaden the search a little bit + mobj = re.search(r'[^A-Za-z0-9]?(?:file|source)=(http[^\'"&]*)', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) + return + + # It's possible that one of the regexes + # matched, but returned an empty group: + if mobj.group(1) is None: + self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) + return + + video_url = urllib.unquote(mobj.group(1)) + video_id = os.path.basename(video_url) + + # here's a fun little line of code for you: + video_extension = os.path.splitext(video_id)[1][1:] + video_id = os.path.splitext(video_id)[0] + + # it's tempting to parse this further, but you would + # have to take into account all the variations like + # Video Title - Site Name + # Site Name | Video Title + # Video Title - Tagline | Site Name + # and so on and so forth; it's just not practical + mobj = re.search(r'(.*)', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract title') + return + video_title = mobj.group(1).decode('utf-8') + video_title = sanitize_title(video_title) + simple_title = simplify_title(video_title) + + # video uploader is domain name + mobj = re.match(r'(?:https?://)?([^/]*)/.*', url) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract title') + return + video_uploader = mobj.group(1).decode('utf-8') + + try: + # Process video information + self._downloader.process_info({ + 'id': video_id.decode('utf-8'), + 'url': video_url.decode('utf-8'), + 'uploader': video_uploader, + 'upload_date': u'NA', + 'title': video_title, + 'stitle': simple_title, + 'ext': video_extension.decode('utf-8'), + 'format': u'NA', + 'player_url': None, + }) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class YoutubeSearchIE(InfoExtractor): + """Information Extractor for YouTube search queries.""" + _VALID_URL = r'ytsearch(\d+|all)?:[\s\S]+' + _API_URL = 'https://gdata.youtube.com/feeds/api/videos?q=%s&start-index=%i&max-results=50&v=2&alt=jsonc' + _youtube_ie = None + _max_youtube_results = 1000 + IE_NAME = u'youtube:search' + + def __init__(self, youtube_ie, downloader=None): + InfoExtractor.__init__(self, downloader) + self._youtube_ie = youtube_ie + + def report_download_page(self, query, pagenum): + """Report attempt to download playlist page with given number.""" + query = query.decode(preferredencoding()) + self._downloader.to_screen(u'[youtube] query "%s": Downloading page %s' % (query, pagenum)) + + def _real_initialize(self): + self._youtube_ie.initialize() + + def _real_extract(self, query): + mobj = re.match(self._VALID_URL, query) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid search query "%s"' % query) + return + + prefix, query = query.split(':') + prefix = prefix[8:] + query = query.encode('utf-8') + if prefix == '': + self._download_n_results(query, 1) + return + elif prefix == 'all': + self._download_n_results(query, self._max_youtube_results) + return + else: + try: + n = long(prefix) + if n <= 0: + self._downloader.trouble(u'ERROR: invalid download number %s for query "%s"' % (n, query)) + return + elif n > self._max_youtube_results: + self._downloader.to_stderr(u'WARNING: ytsearch returns max %i results (you requested %i)' % (self._max_youtube_results, n)) + n = self._max_youtube_results + self._download_n_results(query, n) + return + except ValueError: # parsing prefix as integer fails + self._download_n_results(query, 1) + return + + def _download_n_results(self, query, n): + """Downloads a specified number of results for a query""" + + video_ids = [] + pagenum = 0 + limit = n + + while (50 * pagenum) < limit: + self.report_download_page(query, pagenum+1) + result_url = self._API_URL % (urllib.quote_plus(query), (50*pagenum)+1) + request = urllib2.Request(result_url) + try: + data = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download API page: %s' % str(err)) + return + api_response = json.loads(data)['data'] + + new_ids = list(video['id'] for video in api_response['items']) + video_ids += new_ids + + limit = min(n, api_response['totalItems']) + pagenum += 1 + + if len(video_ids) > n: + video_ids = video_ids[:n] + for id in video_ids: + self._youtube_ie.extract('http://www.youtube.com/watch?v=%s' % id) + return + + +class GoogleSearchIE(InfoExtractor): + """Information Extractor for Google Video search queries.""" + _VALID_URL = r'gvsearch(\d+|all)?:[\s\S]+' + _TEMPLATE_URL = 'http://video.google.com/videosearch?q=%s+site:video.google.com&start=%s&hl=en' + _VIDEO_INDICATOR = r' self._max_google_results: + self._downloader.to_stderr(u'WARNING: gvsearch returns max %i results (you requested %i)' % (self._max_google_results, n)) + n = self._max_google_results + self._download_n_results(query, n) + return + except ValueError: # parsing prefix as integer fails + self._download_n_results(query, 1) + return + + def _download_n_results(self, query, n): + """Downloads a specified number of results for a query""" + + video_ids = [] + pagenum = 0 + + while True: + self.report_download_page(query, pagenum) + result_url = self._TEMPLATE_URL % (urllib.quote_plus(query), pagenum*10) + request = urllib2.Request(result_url) + try: + page = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download webpage: %s' % str(err)) + return + + # Extract video identifiers + for mobj in re.finditer(self._VIDEO_INDICATOR, page): + video_id = mobj.group(1) + if video_id not in video_ids: + video_ids.append(video_id) + if len(video_ids) == n: + # Specified n videos reached + for id in video_ids: + self._google_ie.extract('http://video.google.com/videoplay?docid=%s' % id) + return + + if re.search(self._MORE_PAGES_INDICATOR, page) is None: + for id in video_ids: + self._google_ie.extract('http://video.google.com/videoplay?docid=%s' % id) + return + + pagenum = pagenum + 1 + + +class YahooSearchIE(InfoExtractor): + """Information Extractor for Yahoo! Video search queries.""" + _VALID_URL = r'yvsearch(\d+|all)?:[\s\S]+' + _TEMPLATE_URL = 'http://video.yahoo.com/search/?p=%s&o=%s' + _VIDEO_INDICATOR = r'href="http://video\.yahoo\.com/watch/([0-9]+/[0-9]+)"' + _MORE_PAGES_INDICATOR = r'\s*Next' + _yahoo_ie = None + _max_yahoo_results = 1000 + IE_NAME = u'video.yahoo:search' + + def __init__(self, yahoo_ie, downloader=None): + InfoExtractor.__init__(self, downloader) + self._yahoo_ie = yahoo_ie + + def report_download_page(self, query, pagenum): + """Report attempt to download playlist page with given number.""" + query = query.decode(preferredencoding()) + self._downloader.to_screen(u'[video.yahoo] query "%s": Downloading page %s' % (query, pagenum)) + + def _real_initialize(self): + self._yahoo_ie.initialize() + + def _real_extract(self, query): + mobj = re.match(self._VALID_URL, query) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid search query "%s"' % query) + return + + prefix, query = query.split(':') + prefix = prefix[8:] + query = query.encode('utf-8') + if prefix == '': + self._download_n_results(query, 1) + return + elif prefix == 'all': + self._download_n_results(query, self._max_yahoo_results) + return + else: + try: + n = long(prefix) + if n <= 0: + self._downloader.trouble(u'ERROR: invalid download number %s for query "%s"' % (n, query)) + return + elif n > self._max_yahoo_results: + self._downloader.to_stderr(u'WARNING: yvsearch returns max %i results (you requested %i)' % (self._max_yahoo_results, n)) + n = self._max_yahoo_results + self._download_n_results(query, n) + return + except ValueError: # parsing prefix as integer fails + self._download_n_results(query, 1) + return + + def _download_n_results(self, query, n): + """Downloads a specified number of results for a query""" + + video_ids = [] + already_seen = set() + pagenum = 1 + + while True: + self.report_download_page(query, pagenum) + result_url = self._TEMPLATE_URL % (urllib.quote_plus(query), pagenum) + request = urllib2.Request(result_url) + try: + page = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download webpage: %s' % str(err)) + return + + # Extract video identifiers + for mobj in re.finditer(self._VIDEO_INDICATOR, page): + video_id = mobj.group(1) + if video_id not in already_seen: + video_ids.append(video_id) + already_seen.add(video_id) + if len(video_ids) == n: + # Specified n videos reached + for id in video_ids: + self._yahoo_ie.extract('http://video.yahoo.com/watch/%s' % id) + return + + if re.search(self._MORE_PAGES_INDICATOR, page) is None: + for id in video_ids: + self._yahoo_ie.extract('http://video.yahoo.com/watch/%s' % id) + return + + pagenum = pagenum + 1 + + +class YoutubePlaylistIE(InfoExtractor): + """Information Extractor for YouTube playlists.""" + + _VALID_URL = r'(?:https?://)?(?:\w+\.)?youtube\.com/(?:(?:course|view_play_list|my_playlists|artist|playlist)\?.*?(p|a|list)=|user/.*?/user/|p/|user/.*?#[pg]/c/)(?:PL)?([0-9A-Za-z-_]+)(?:/.*?/([0-9A-Za-z_-]+))?.*' + _TEMPLATE_URL = 'http://www.youtube.com/%s?%s=%s&page=%s&gl=US&hl=en' + _VIDEO_INDICATOR_TEMPLATE = r'/watch\?v=(.+?)&list=PL%s&' + _MORE_PAGES_INDICATOR = r'(?m)>\s*Next\s*' + _youtube_ie = None + IE_NAME = u'youtube:playlist' + + def __init__(self, youtube_ie, downloader=None): + InfoExtractor.__init__(self, downloader) + self._youtube_ie = youtube_ie + + def report_download_page(self, playlist_id, pagenum): + """Report attempt to download playlist page with given number.""" + self._downloader.to_screen(u'[youtube] PL %s: Downloading page #%s' % (playlist_id, pagenum)) + + def _real_initialize(self): + self._youtube_ie.initialize() + + def _real_extract(self, url): + # Extract playlist id + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid url: %s' % url) + return + + # Single video case + if mobj.group(3) is not None: + self._youtube_ie.extract(mobj.group(3)) + return + + # Download playlist pages + # prefix is 'p' as default for playlists but there are other types that need extra care + playlist_prefix = mobj.group(1) + if playlist_prefix == 'a': + playlist_access = 'artist' + else: + playlist_prefix = 'p' + playlist_access = 'view_play_list' + playlist_id = mobj.group(2) + video_ids = [] + pagenum = 1 + + while True: + self.report_download_page(playlist_id, pagenum) + url = self._TEMPLATE_URL % (playlist_access, playlist_prefix, playlist_id, pagenum) + request = urllib2.Request(url) + try: + page = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download webpage: %s' % str(err)) + return + + # Extract video identifiers + ids_in_page = [] + for mobj in re.finditer(self._VIDEO_INDICATOR_TEMPLATE % playlist_id, page): + if mobj.group(1) not in ids_in_page: + ids_in_page.append(mobj.group(1)) + video_ids.extend(ids_in_page) + + if re.search(self._MORE_PAGES_INDICATOR, page) is None: + break + pagenum = pagenum + 1 + + playliststart = self._downloader.params.get('playliststart', 1) - 1 + playlistend = self._downloader.params.get('playlistend', -1) + if playlistend == -1: + video_ids = video_ids[playliststart:] + else: + video_ids = video_ids[playliststart:playlistend] + + for id in video_ids: + self._youtube_ie.extract('http://www.youtube.com/watch?v=%s' % id) + return + + +class YoutubeUserIE(InfoExtractor): + """Information Extractor for YouTube users.""" + + _VALID_URL = r'(?:(?:(?:https?://)?(?:\w+\.)?youtube\.com/user/)|ytuser:)([A-Za-z0-9_-]+)' + _TEMPLATE_URL = 'http://gdata.youtube.com/feeds/api/users/%s' + _GDATA_PAGE_SIZE = 50 + _GDATA_URL = 'http://gdata.youtube.com/feeds/api/users/%s/uploads?max-results=%d&start-index=%d' + _VIDEO_INDICATOR = r'/watch\?v=(.+?)[\<&]' + _youtube_ie = None + IE_NAME = u'youtube:user' + + def __init__(self, youtube_ie, downloader=None): + InfoExtractor.__init__(self, downloader) + self._youtube_ie = youtube_ie + + def report_download_page(self, username, start_index): + """Report attempt to download user page.""" + self._downloader.to_screen(u'[youtube] user %s: Downloading video ids from %d to %d' % + (username, start_index, start_index + self._GDATA_PAGE_SIZE)) + + def _real_initialize(self): + self._youtube_ie.initialize() + + def _real_extract(self, url): + # Extract username + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid url: %s' % url) + return + + username = mobj.group(1) + + # Download video ids using YouTube Data API. Result size per + # query is limited (currently to 50 videos) so we need to query + # page by page until there are no video ids - it means we got + # all of them. + + video_ids = [] + pagenum = 0 + + while True: + start_index = pagenum * self._GDATA_PAGE_SIZE + 1 + self.report_download_page(username, start_index) + + request = urllib2.Request(self._GDATA_URL % (username, self._GDATA_PAGE_SIZE, start_index)) + + try: + page = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download webpage: %s' % str(err)) + return + + # Extract video identifiers + ids_in_page = [] + + for mobj in re.finditer(self._VIDEO_INDICATOR, page): + if mobj.group(1) not in ids_in_page: + ids_in_page.append(mobj.group(1)) + + video_ids.extend(ids_in_page) + + # A little optimization - if current page is not + # "full", ie. does not contain PAGE_SIZE video ids then + # we can assume that this page is the last one - there + # are no more ids on further pages - no need to query + # again. + + if len(ids_in_page) < self._GDATA_PAGE_SIZE: + break + + pagenum += 1 + + all_ids_count = len(video_ids) + playliststart = self._downloader.params.get('playliststart', 1) - 1 + playlistend = self._downloader.params.get('playlistend', -1) + + if playlistend == -1: + video_ids = video_ids[playliststart:] + else: + video_ids = video_ids[playliststart:playlistend] + + self._downloader.to_screen(u"[youtube] user %s: Collected %d video ids (downloading %d of them)" % + (username, all_ids_count, len(video_ids))) + + for video_id in video_ids: + self._youtube_ie.extract('http://www.youtube.com/watch?v=%s' % video_id) + + +class DepositFilesIE(InfoExtractor): + """Information extractor for depositfiles.com""" + + _VALID_URL = r'(?:http://)?(?:\w+\.)?depositfiles\.com/(?:../(?#locale))?files/(.+)' + IE_NAME = u'DepositFiles' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_download_webpage(self, file_id): + """Report webpage download.""" + self._downloader.to_screen(u'[DepositFiles] %s: Downloading webpage' % file_id) + + def report_extraction(self, file_id): + """Report information extraction.""" + self._downloader.to_screen(u'[DepositFiles] %s: Extracting information' % file_id) + + def _real_extract(self, url): + # At this point we have a new file + self._downloader.increment_downloads() + + file_id = url.split('/')[-1] + # Rebuild url in english locale + url = 'http://depositfiles.com/en/files/' + file_id + + # Retrieve file webpage with 'Free download' button pressed + free_download_indication = { 'gateway_result' : '1' } + request = urllib2.Request(url, urllib.urlencode(free_download_indication)) + try: + self.report_download_webpage(file_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve file webpage: %s' % str(err)) + return + + # Search for the real file URL + mobj = re.search(r'
(Attention.*?)', webpage, re.DOTALL) + if (mobj is not None) and (mobj.group(1) is not None): + restriction_message = re.sub('\s+', ' ', mobj.group(1)).strip() + self._downloader.trouble(u'ERROR: %s' % restriction_message) + else: + self._downloader.trouble(u'ERROR: unable to extract download URL from: %s' % url) + return + + file_url = mobj.group(1) + file_extension = os.path.splitext(file_url)[1][1:] + + # Search for file title + mobj = re.search(r'', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract title') + return + file_title = mobj.group(1).decode('utf-8') + + try: + # Process file information + self._downloader.process_info({ + 'id': file_id.decode('utf-8'), + 'url': file_url.decode('utf-8'), + 'uploader': u'NA', + 'upload_date': u'NA', + 'title': file_title, + 'stitle': file_title, + 'ext': file_extension.decode('utf-8'), + 'format': u'NA', + 'player_url': None, + }) + except UnavailableVideoError, err: + self._downloader.trouble(u'ERROR: unable to download file') + + +class FacebookIE(InfoExtractor): + """Information Extractor for Facebook""" + + _VALID_URL = r'^(?:https?://)?(?:\w+\.)?facebook\.com/(?:video/video|photo)\.php\?(?:.*?)v=(?P\d+)(?:.*)' + _LOGIN_URL = 'https://login.facebook.com/login.php?m&next=http%3A%2F%2Fm.facebook.com%2Fhome.php&' + _NETRC_MACHINE = 'facebook' + _available_formats = ['video', 'highqual', 'lowqual'] + _video_extensions = { + 'video': 'mp4', + 'highqual': 'mp4', + 'lowqual': 'mp4', + } + IE_NAME = u'facebook' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def _reporter(self, message): + """Add header and report message.""" + self._downloader.to_screen(u'[facebook] %s' % message) + + def report_login(self): + """Report attempt to log in.""" + self._reporter(u'Logging in') + + def report_video_webpage_download(self, video_id): + """Report attempt to download video webpage.""" + self._reporter(u'%s: Downloading video webpage' % video_id) + + def report_information_extraction(self, video_id): + """Report attempt to extract video information.""" + self._reporter(u'%s: Extracting video information' % video_id) + + def _parse_page(self, video_webpage): + """Extract video information from page""" + # General data + data = {'title': r'\("video_title", "(.*?)"\)', + 'description': r'
(.*?)
', + 'owner': r'\("video_owner_name", "(.*?)"\)', + 'thumbnail': r'\("thumb_url", "(?P.*?)"\)', + } + video_info = {} + for piece in data.keys(): + mobj = re.search(data[piece], video_webpage) + if mobj is not None: + video_info[piece] = urllib.unquote_plus(mobj.group(1).decode("unicode_escape")) + + # Video urls + video_urls = {} + for fmt in self._available_formats: + mobj = re.search(r'\("%s_src\", "(.+?)"\)' % fmt, video_webpage) + if mobj is not None: + # URL is in a Javascript segment inside an escaped Unicode format within + # the generally utf-8 page + video_urls[fmt] = urllib.unquote_plus(mobj.group(1).decode("unicode_escape")) + video_info['video_urls'] = video_urls + + return video_info + + def _real_initialize(self): + if self._downloader is None: + return + + useremail = None + password = None + downloader_params = self._downloader.params + + # Attempt to use provided username and password or .netrc data + if downloader_params.get('username', None) is not None: + useremail = downloader_params['username'] + password = downloader_params['password'] + elif downloader_params.get('usenetrc', False): + try: + info = netrc.netrc().authenticators(self._NETRC_MACHINE) + if info is not None: + useremail = info[0] + password = info[2] + else: + raise netrc.NetrcParseError('No authenticators for %s' % self._NETRC_MACHINE) + except (IOError, netrc.NetrcParseError), err: + self._downloader.to_stderr(u'WARNING: parsing .netrc: %s' % str(err)) + return + + if useremail is None: + return + + # Log in + login_form = { + 'email': useremail, + 'pass': password, + 'login': 'Log+In' + } + request = urllib2.Request(self._LOGIN_URL, urllib.urlencode(login_form)) + try: + self.report_login() + login_results = urllib2.urlopen(request).read() + if re.search(r'', login_results) is not None: + self._downloader.to_stderr(u'WARNING: unable to log in: bad username/password, or exceded login rate limit (~3/min). Check credentials or wait.') + return + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.to_stderr(u'WARNING: unable to log in: %s' % str(err)) + return + + def _real_extract(self, url): + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + video_id = mobj.group('ID') + + # Get video webpage + self.report_video_webpage_download(video_id) + request = urllib2.Request('https://www.facebook.com/video/video.php?v=%s' % video_id) + try: + page = urllib2.urlopen(request) + video_webpage = page.read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) + return + + # Start extracting information + self.report_information_extraction(video_id) + + # Extract information + video_info = self._parse_page(video_webpage) + + # uploader + if 'owner' not in video_info: + self._downloader.trouble(u'ERROR: unable to extract uploader nickname') + return + video_uploader = video_info['owner'] + + # title + if 'title' not in video_info: + self._downloader.trouble(u'ERROR: unable to extract video title') + return + video_title = video_info['title'] + video_title = video_title.decode('utf-8') + video_title = sanitize_title(video_title) + + simple_title = simplify_title(video_title) + + # thumbnail image + if 'thumbnail' not in video_info: + self._downloader.trouble(u'WARNING: unable to extract video thumbnail') + video_thumbnail = '' + else: + video_thumbnail = video_info['thumbnail'] + + # upload date + upload_date = u'NA' + if 'upload_date' in video_info: + upload_time = video_info['upload_date'] + timetuple = email.utils.parsedate_tz(upload_time) + if timetuple is not None: + try: + upload_date = time.strftime('%Y%m%d', timetuple[0:9]) + except: + pass + + # description + video_description = video_info.get('description', 'No description available.') + + url_map = video_info['video_urls'] + if len(url_map.keys()) > 0: + # Decide which formats to download + req_format = self._downloader.params.get('format', None) + format_limit = self._downloader.params.get('format_limit', None) + + if format_limit is not None and format_limit in self._available_formats: + format_list = self._available_formats[self._available_formats.index(format_limit):] + else: + format_list = self._available_formats + existing_formats = [x for x in format_list if x in url_map] + if len(existing_formats) == 0: + self._downloader.trouble(u'ERROR: no known formats available for video') + return + if req_format is None: + video_url_list = [(existing_formats[0], url_map[existing_formats[0]])] # Best quality + elif req_format == 'worst': + video_url_list = [(existing_formats[len(existing_formats)-1], url_map[existing_formats[len(existing_formats)-1]])] # worst quality + elif req_format == '-1': + video_url_list = [(f, url_map[f]) for f in existing_formats] # All formats + else: + # Specific format + if req_format not in url_map: + self._downloader.trouble(u'ERROR: requested format not available') + return + video_url_list = [(req_format, url_map[req_format])] # Specific format + + for format_param, video_real_url in video_url_list: + + # At this point we have a new video + self._downloader.increment_downloads() + + # Extension + video_extension = self._video_extensions.get(format_param, 'mp4') + + try: + # Process video information + self._downloader.process_info({ + 'id': video_id.decode('utf-8'), + 'url': video_real_url.decode('utf-8'), + 'uploader': video_uploader.decode('utf-8'), + 'upload_date': upload_date, + 'title': video_title, + 'stitle': simple_title, + 'ext': video_extension.decode('utf-8'), + 'format': (format_param is None and u'NA' or format_param.decode('utf-8')), + 'thumbnail': video_thumbnail.decode('utf-8'), + 'description': video_description.decode('utf-8'), + 'player_url': None, + }) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download video') + +class BlipTVIE(InfoExtractor): + """Information extractor for blip.tv""" + + _VALID_URL = r'^(?:https?://)?(?:\w+\.)?blip\.tv(/.+)$' + _URL_EXT = r'^.*\.([a-z0-9]+)$' + IE_NAME = u'blip.tv' + + def report_extraction(self, file_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, file_id)) + + def report_direct_download(self, title): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Direct download detected' % (self.IE_NAME, title)) + + def _real_extract(self, url): + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + + if '?' in url: + cchar = '&' + else: + cchar = '?' + json_url = url + cchar + 'skin=json&version=2&no_wrap=1' + request = urllib2.Request(json_url) + self.report_extraction(mobj.group(1)) + info = None + try: + urlh = urllib2.urlopen(request) + if urlh.headers.get('Content-Type', '').startswith('video/'): # Direct download + basename = url.split('/')[-1] + title,ext = os.path.splitext(basename) + title = title.decode('UTF-8') + ext = ext.replace('.', '') + self.report_direct_download(title) + info = { + 'id': title, + 'url': url, + 'title': title, + 'stitle': simplify_title(title), + 'ext': ext, + 'urlhandle': urlh + } + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video info webpage: %s' % str(err)) + return + if info is None: # Regular URL + try: + json_code = urlh.read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to read video info webpage: %s' % str(err)) + return + + try: + json_data = json.loads(json_code) + if 'Post' in json_data: + data = json_data['Post'] + else: + data = json_data + + upload_date = datetime.datetime.strptime(data['datestamp'], '%m-%d-%y %H:%M%p').strftime('%Y%m%d') + video_url = data['media']['url'] + umobj = re.match(self._URL_EXT, video_url) + if umobj is None: + raise ValueError('Can not determine filename extension') + ext = umobj.group(1) + + info = { + 'id': data['item_id'], + 'url': video_url, + 'uploader': data['display_name'], + 'upload_date': upload_date, + 'title': data['title'], + 'stitle': simplify_title(data['title']), + 'ext': ext, + 'format': data['media']['mimeType'], + 'thumbnail': data['thumbnailUrl'], + 'description': data['description'], + 'player_url': data['embedUrl'] + } + except (ValueError,KeyError), err: + self._downloader.trouble(u'ERROR: unable to parse video information: %s' % repr(err)) + return + + self._downloader.increment_downloads() + + try: + self._downloader.process_info(info) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class MyVideoIE(InfoExtractor): + """Information Extractor for myvideo.de.""" + + _VALID_URL = r'(?:http://)?(?:www\.)?myvideo\.de/watch/([0-9]+)/([^?/]+).*' + IE_NAME = u'myvideo' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_download_webpage(self, video_id): + """Report webpage download.""" + self._downloader.to_screen(u'[myvideo] %s: Downloading webpage' % video_id) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[myvideo] %s: Extracting information' % video_id) + + def _real_extract(self,url): + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._download.trouble(u'ERROR: invalid URL: %s' % url) + return + + video_id = mobj.group(1) + + # Get video webpage + request = urllib2.Request('http://www.myvideo.de/watch/%s' % video_id) + try: + self.report_download_webpage(video_id) + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) + return + + self.report_extraction(video_id) + mobj = re.search(r'', + webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract media URL') + return + video_url = mobj.group(1) + ('/%s.flv' % video_id) + + mobj = re.search('([^<]+)', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract title') + return + + video_title = mobj.group(1) + video_title = sanitize_title(video_title) + + simple_title = simplify_title(video_title) + + try: + self._downloader.process_info({ + 'id': video_id, + 'url': video_url, + 'uploader': u'NA', + 'upload_date': u'NA', + 'title': video_title, + 'stitle': simple_title, + 'ext': u'flv', + 'format': u'NA', + 'player_url': None, + }) + except UnavailableVideoError: + self._downloader.trouble(u'\nERROR: Unable to download video') + +class ComedyCentralIE(InfoExtractor): + """Information extractor for The Daily Show and Colbert Report """ + + _VALID_URL = r'^(:(?Ptds|thedailyshow|cr|colbert|colbertnation|colbertreport))|(https?://)?(www\.)?(?Pthedailyshow|colbertnation)\.com/full-episodes/(?P.*)$' + IE_NAME = u'comedycentral' + + def report_extraction(self, episode_id): + self._downloader.to_screen(u'[comedycentral] %s: Extracting information' % episode_id) + + def report_config_download(self, episode_id): + self._downloader.to_screen(u'[comedycentral] %s: Downloading configuration' % episode_id) + + def report_index_download(self, episode_id): + self._downloader.to_screen(u'[comedycentral] %s: Downloading show index' % episode_id) + + def report_player_url(self, episode_id): + self._downloader.to_screen(u'[comedycentral] %s: Determining player URL' % episode_id) + + def _real_extract(self, url): + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + + if mobj.group('shortname'): + if mobj.group('shortname') in ('tds', 'thedailyshow'): + url = u'http://www.thedailyshow.com/full-episodes/' + else: + url = u'http://www.colbertnation.com/full-episodes/' + mobj = re.match(self._VALID_URL, url) + assert mobj is not None + + dlNewest = not mobj.group('episode') + if dlNewest: + epTitle = mobj.group('showname') + else: + epTitle = mobj.group('episode') + + req = urllib2.Request(url) + self.report_extraction(epTitle) + try: + htmlHandle = urllib2.urlopen(req) + html = htmlHandle.read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download webpage: %s' % unicode(err)) + return + if dlNewest: + url = htmlHandle.geturl() + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: Invalid redirected URL: ' + url) + return + if mobj.group('episode') == '': + self._downloader.trouble(u'ERROR: Redirected URL is still not specific: ' + url) + return + epTitle = mobj.group('episode') + + mMovieParams = re.findall('(?:[^/]+)/(?P[^/?]+)[/?]?.*$' + IE_NAME = u'escapist' + + def report_extraction(self, showName): + self._downloader.to_screen(u'[escapist] %s: Extracting information' % showName) + + def report_config_download(self, showName): + self._downloader.to_screen(u'[escapist] %s: Downloading configuration' % showName) + + def _real_extract(self, url): + htmlParser = HTMLParser.HTMLParser() + + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + showName = mobj.group('showname') + videoId = mobj.group('episode') + + self.report_extraction(showName) + try: + webPage = urllib2.urlopen(url).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download webpage: ' + unicode(err)) + return + + descMatch = re.search('[0-9]+)/(?P.*)$' + IE_NAME = u'collegehumor' + + def report_webpage(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) + + def _real_extract(self, url): + htmlParser = HTMLParser.HTMLParser() + + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + video_id = mobj.group('videoid') + + self.report_webpage(video_id) + request = urllib2.Request(url) + try: + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) + return + + m = re.search(r'id="video:(?P[0-9]+)"', webpage) + if m is None: + self._downloader.trouble(u'ERROR: Cannot extract internal video ID') + return + internal_video_id = m.group('internalvideoid') + + info = { + 'id': video_id, + 'internal_id': internal_video_id, + } + + self.report_extraction(video_id) + xmlUrl = 'http://www.collegehumor.com/moogaloop/video:' + internal_video_id + try: + metaXml = urllib2.urlopen(xmlUrl).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video info XML: %s' % str(err)) + return + + mdoc = xml.etree.ElementTree.fromstring(metaXml) + try: + videoNode = mdoc.findall('./video')[0] + info['description'] = videoNode.findall('./description')[0].text + info['title'] = videoNode.findall('./caption')[0].text + info['stitle'] = simplify_title(info['title']) + info['url'] = videoNode.findall('./file')[0].text + info['thumbnail'] = videoNode.findall('./thumbnail')[0].text + info['ext'] = info['url'].rpartition('.')[2] + info['format'] = info['ext'] + except IndexError: + self._downloader.trouble(u'\nERROR: Invalid metadata XML file') + return + + self._downloader.increment_downloads() + + try: + self._downloader.process_info(info) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class XVideosIE(InfoExtractor): + """Information extractor for xvideos.com""" + + _VALID_URL = r'^(?:https?://)?(?:www\.)?xvideos\.com/video([0-9]+)(?:.*)' + IE_NAME = u'xvideos' + + def report_webpage(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) + + def _real_extract(self, url): + htmlParser = HTMLParser.HTMLParser() + + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + video_id = mobj.group(1).decode('utf-8') + + self.report_webpage(video_id) + + request = urllib2.Request(r'http://www.xvideos.com/video' + video_id) + try: + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) + return + + self.report_extraction(video_id) + + + # Extract video URL + mobj = re.search(r'flv_url=(.+?)&', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video url') + return + video_url = urllib2.unquote(mobj.group(1).decode('utf-8')) + + + # Extract title + mobj = re.search(r'(.*?)\s+-\s+XVID', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video title') + return + video_title = mobj.group(1).decode('utf-8') + + + # Extract video thumbnail + mobj = re.search(r'http://(?:img.*?\.)xvideos.com/videos/thumbs/[a-fA-F0-9]/[a-fA-F0-9]/[a-fA-F0-9]/([a-fA-F0-9.]+jpg)', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video thumbnail') + return + video_thumbnail = mobj.group(1).decode('utf-8') + + + + self._downloader.increment_downloads() + info = { + 'id': video_id, + 'url': video_url, + 'uploader': None, + 'upload_date': None, + 'title': video_title, + 'stitle': simplify_title(video_title), + 'ext': 'flv', + 'format': 'flv', + 'thumbnail': video_thumbnail, + 'description': None, + 'player_url': None, + } + + try: + self._downloader.process_info(info) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download ' + video_id) + + +class SoundcloudIE(InfoExtractor): + """Information extractor for soundcloud.com + To access the media, the uid of the song and a stream token + must be extracted from the page source and the script must make + a request to media.soundcloud.com/crossdomain.xml. Then + the media can be grabbed by requesting from an url composed + of the stream token and uid + """ + + _VALID_URL = r'^(?:https?://)?(?:www\.)?soundcloud\.com/([\w\d-]+)/([\w\d-]+)' + IE_NAME = u'soundcloud' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_webpage(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) + + def _real_extract(self, url): + htmlParser = HTMLParser.HTMLParser() + + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + + # extract uploader (which is in the url) + uploader = mobj.group(1).decode('utf-8') + # extract simple title (uploader + slug of song title) + slug_title = mobj.group(2).decode('utf-8') + simple_title = uploader + '-' + slug_title + + self.report_webpage('%s/%s' % (uploader, slug_title)) + + request = urllib2.Request('http://soundcloud.com/%s/%s' % (uploader, slug_title)) + try: + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) + return + + self.report_extraction('%s/%s' % (uploader, slug_title)) + + # extract uid and stream token that soundcloud hands out for access + mobj = re.search('"uid":"([\w\d]+?)".*?stream_token=([\w\d]+)', webpage) + if mobj: + video_id = mobj.group(1) + stream_token = mobj.group(2) + + # extract unsimplified title + mobj = re.search('"title":"(.*?)",', webpage) + if mobj: + title = mobj.group(1) + + # construct media url (with uid/token) + mediaURL = "http://media.soundcloud.com/stream/%s?stream_token=%s" + mediaURL = mediaURL % (video_id, stream_token) + + # description + description = u'No description available' + mobj = re.search('track-description-value"><p>(.*?)</p>', webpage) + if mobj: + description = mobj.group(1) + + # upload date + upload_date = None + mobj = re.search("pretty-date'>on ([\w]+ [\d]+, [\d]+ \d+:\d+)</abbr></h2>", webpage) + if mobj: + try: + upload_date = datetime.datetime.strptime(mobj.group(1), '%B %d, %Y %H:%M').strftime('%Y%m%d') + except Exception, e: + print str(e) + + # for soundcloud, a request to a cross domain is required for cookies + request = urllib2.Request('http://media.soundcloud.com/crossdomain.xml', std_headers) + + try: + self._downloader.process_info({ + 'id': video_id.decode('utf-8'), + 'url': mediaURL, + 'uploader': uploader.decode('utf-8'), + 'upload_date': upload_date, + 'title': simple_title.decode('utf-8'), + 'stitle': simple_title.decode('utf-8'), + 'ext': u'mp3', + 'format': u'NA', + 'player_url': None, + 'description': description.decode('utf-8') + }) + except UnavailableVideoError: + self._downloader.trouble(u'\nERROR: unable to download video') + + +class InfoQIE(InfoExtractor): + """Information extractor for infoq.com""" + + _VALID_URL = r'^(?:https?://)?(?:www\.)?infoq\.com/[^/]+/[^/]+$' + IE_NAME = u'infoq' + + def report_webpage(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) + + def _real_extract(self, url): + htmlParser = HTMLParser.HTMLParser() + + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + + self.report_webpage(url) + + request = urllib2.Request(url) + try: + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) + return + + self.report_extraction(url) + + + # Extract video URL + mobj = re.search(r"jsclassref='([^']*)'", webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video url') + return + video_url = 'rtmpe://video.infoq.com/cfx/st/' + urllib2.unquote(mobj.group(1).decode('base64')) + + + # Extract title + mobj = re.search(r'contentTitle = "(.*?)";', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract video title') + return + video_title = mobj.group(1).decode('utf-8') + + # Extract description + video_description = u'No description available.' + mobj = re.search(r'<meta name="description" content="(.*)"(?:\s*/)?>', webpage) + if mobj is not None: + video_description = mobj.group(1).decode('utf-8') + + video_filename = video_url.split('/')[-1] + video_id, extension = video_filename.split('.') + + self._downloader.increment_downloads() + info = { + 'id': video_id, + 'url': video_url, + 'uploader': None, + 'upload_date': None, + 'title': video_title, + 'stitle': simplify_title(video_title), + 'ext': extension, + 'format': extension, # Extension is always(?) mp4, but seems to be flv + 'thumbnail': None, + 'description': video_description, + 'player_url': None, + } + + try: + self._downloader.process_info(info) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download ' + video_url) + +class MixcloudIE(InfoExtractor): + """Information extractor for www.mixcloud.com""" + _VALID_URL = r'^(?:https?://)?(?:www\.)?mixcloud\.com/([\w\d-]+)/([\w\d-]+)' + IE_NAME = u'mixcloud' + + def __init__(self, downloader=None): + InfoExtractor.__init__(self, downloader) + + def report_download_json(self, file_id): + """Report JSON download.""" + self._downloader.to_screen(u'[%s] Downloading json' % self.IE_NAME) + + def report_extraction(self, file_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, file_id)) + + def get_urls(self, jsonData, fmt, bitrate='best'): + """Get urls from 'audio_formats' section in json""" + file_url = None + try: + bitrate_list = jsonData[fmt] + if bitrate is None or bitrate == 'best' or bitrate not in bitrate_list: + bitrate = max(bitrate_list) # select highest + + url_list = jsonData[fmt][bitrate] + except TypeError: # we have no bitrate info. + url_list = jsonData[fmt] + + return url_list + + def check_urls(self, url_list): + """Returns 1st active url from list""" + for url in url_list: + try: + urllib2.urlopen(url) + return url + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + url = None + + return None + + def _print_formats(self, formats): + print 'Available formats:' + for fmt in formats.keys(): + for b in formats[fmt]: + try: + ext = formats[fmt][b][0] + print '%s\t%s\t[%s]' % (fmt, b, ext.split('.')[-1]) + except TypeError: # we have no bitrate info + ext = formats[fmt][0] + print '%s\t%s\t[%s]' % (fmt, '??', ext.split('.')[-1]) + break + + def _real_extract(self, url): + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + # extract uploader & filename from url + uploader = mobj.group(1).decode('utf-8') + file_id = uploader + "-" + mobj.group(2).decode('utf-8') + + # construct API request + file_url = 'http://www.mixcloud.com/api/1/cloudcast/' + '/'.join(url.split('/')[-3:-1]) + '.json' + # retrieve .json file with links to files + request = urllib2.Request(file_url) + try: + self.report_download_json(file_url) + jsonData = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: Unable to retrieve file: %s' % str(err)) + return + + # parse JSON + json_data = json.loads(jsonData) + player_url = json_data['player_swf_url'] + formats = dict(json_data['audio_formats']) + + req_format = self._downloader.params.get('format', None) + bitrate = None + + if self._downloader.params.get('listformats', None): + self._print_formats(formats) + return + + if req_format is None or req_format == 'best': + for format_param in formats.keys(): + url_list = self.get_urls(formats, format_param) + # check urls + file_url = self.check_urls(url_list) + if file_url is not None: + break # got it! + else: + if req_format not in formats.keys(): + self._downloader.trouble(u'ERROR: format is not available') + return + + url_list = self.get_urls(formats, req_format) + file_url = self.check_urls(url_list) + format_param = req_format + + # We have audio + self._downloader.increment_downloads() + try: + # Process file information + self._downloader.process_info({ + 'id': file_id.decode('utf-8'), + 'url': file_url.decode('utf-8'), + 'uploader': uploader.decode('utf-8'), + 'upload_date': u'NA', + 'title': json_data['name'], + 'stitle': simplify_title(json_data['name']), + 'ext': file_url.split('.')[-1].decode('utf-8'), + 'format': (format_param is None and u'NA' or format_param.decode('utf-8')), + 'thumbnail': json_data['thumbnail_url'], + 'description': json_data['description'], + 'player_url': player_url.decode('utf-8'), + }) + except UnavailableVideoError, err: + self._downloader.trouble(u'ERROR: unable to download file') + +class StanfordOpenClassroomIE(InfoExtractor): + """Information extractor for Stanford's Open ClassRoom""" + + _VALID_URL = r'^(?:https?://)?openclassroom.stanford.edu(?P<path>/?|(/MainFolder/(?:HomePage|CoursePage|VideoPage)\.php([?]course=(?P<course>[^&]+)(&video=(?P<video>[^&]+))?(&.*)?)?))$' + IE_NAME = u'stanfordoc' + + def report_download_webpage(self, objid): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, objid)) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) + + def _real_extract(self, url): + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + + if mobj.group('course') and mobj.group('video'): # A specific video + course = mobj.group('course') + video = mobj.group('video') + info = { + 'id': simplify_title(course + '_' + video), + } + + self.report_extraction(info['id']) + baseUrl = 'http://openclassroom.stanford.edu/MainFolder/courses/' + course + '/videos/' + xmlUrl = baseUrl + video + '.xml' + try: + metaXml = urllib2.urlopen(xmlUrl).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video info XML: %s' % unicode(err)) + return + mdoc = xml.etree.ElementTree.fromstring(metaXml) + try: + info['title'] = mdoc.findall('./title')[0].text + info['url'] = baseUrl + mdoc.findall('./videoFile')[0].text + except IndexError: + self._downloader.trouble(u'\nERROR: Invalid metadata XML file') + return + info['stitle'] = simplify_title(info['title']) + info['ext'] = info['url'].rpartition('.')[2] + info['format'] = info['ext'] + self._downloader.increment_downloads() + try: + self._downloader.process_info(info) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download video') + elif mobj.group('course'): # A course page + unescapeHTML = HTMLParser.HTMLParser().unescape + + course = mobj.group('course') + info = { + 'id': simplify_title(course), + 'type': 'playlist', + } + + self.report_download_webpage(info['id']) + try: + coursepage = urllib2.urlopen(url).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download course info page: ' + unicode(err)) + return + + m = re.search('<h1>([^<]+)</h1>', coursepage) + if m: + info['title'] = unescapeHTML(m.group(1)) + else: + info['title'] = info['id'] + info['stitle'] = simplify_title(info['title']) + + m = re.search('<description>([^<]+)</description>', coursepage) + if m: + info['description'] = unescapeHTML(m.group(1)) + + links = orderedSet(re.findall('<a href="(VideoPage.php\?[^"]+)">', coursepage)) + info['list'] = [ + { + 'type': 'reference', + 'url': 'http://openclassroom.stanford.edu/MainFolder/' + unescapeHTML(vpage), + } + for vpage in links] + + for entry in info['list']: + assert entry['type'] == 'reference' + self.extract(entry['url']) + else: # Root page + unescapeHTML = HTMLParser.HTMLParser().unescape + + info = { + 'id': 'Stanford OpenClassroom', + 'type': 'playlist', + } + + self.report_download_webpage(info['id']) + rootURL = 'http://openclassroom.stanford.edu/MainFolder/HomePage.php' + try: + rootpage = urllib2.urlopen(rootURL).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download course info page: ' + unicode(err)) + return + + info['title'] = info['id'] + info['stitle'] = simplify_title(info['title']) + + links = orderedSet(re.findall('<a href="(CoursePage.php\?[^"]+)">', rootpage)) + info['list'] = [ + { + 'type': 'reference', + 'url': 'http://openclassroom.stanford.edu/MainFolder/' + unescapeHTML(cpage), + } + for cpage in links] + + for entry in info['list']: + assert entry['type'] == 'reference' + self.extract(entry['url']) + +class MTVIE(InfoExtractor): + """Information extractor for MTV.com""" + + _VALID_URL = r'^(?P<proto>https?://)?(?:www\.)?mtv\.com/videos/[^/]+/(?P<videoid>[0-9]+)/[^/]+$' + IE_NAME = u'mtv' + + def report_webpage(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) + + def report_extraction(self, video_id): + """Report information extraction.""" + self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) + + def _real_extract(self, url): + mobj = re.match(self._VALID_URL, url) + if mobj is None: + self._downloader.trouble(u'ERROR: invalid URL: %s' % url) + return + if not mobj.group('proto'): + url = 'http://' + url + video_id = mobj.group('videoid') + self.report_webpage(video_id) + + request = urllib2.Request(url) + try: + webpage = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) + return + + mobj = re.search(r'<meta name="mtv_vt" content="([^"]+)"/>', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract song name') + return + song_name = unescapeHTML(mobj.group(1).decode('iso-8859-1')) + mobj = re.search(r'<meta name="mtv_an" content="([^"]+)"/>', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract performer') + return + performer = unescapeHTML(mobj.group(1).decode('iso-8859-1')) + video_title = performer + ' - ' + song_name + + mobj = re.search(r'<meta name="mtvn_uri" content="([^"]+)"/>', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to mtvn_uri') + return + mtvn_uri = mobj.group(1) + + mobj = re.search(r'MTVN.Player.defaultPlaylistId = ([0-9]+);', webpage) + if mobj is None: + self._downloader.trouble(u'ERROR: unable to extract content id') + return + content_id = mobj.group(1) + + videogen_url = 'http://www.mtv.com/player/includes/mediaGen.jhtml?uri=' + mtvn_uri + '&id=' + content_id + '&vid=' + video_id + '&ref=www.mtvn.com&viewUri=' + mtvn_uri + self.report_extraction(video_id) + request = urllib2.Request(videogen_url) + try: + metadataXml = urllib2.urlopen(request).read() + except (urllib2.URLError, httplib.HTTPException, socket.error), err: + self._downloader.trouble(u'ERROR: unable to download video metadata: %s' % str(err)) + return + + mdoc = xml.etree.ElementTree.fromstring(metadataXml) + renditions = mdoc.findall('.//rendition') + + # For now, always pick the highest quality. + rendition = renditions[-1] + + try: + _,_,ext = rendition.attrib['type'].partition('/') + format = ext + '-' + rendition.attrib['width'] + 'x' + rendition.attrib['height'] + '_' + rendition.attrib['bitrate'] + video_url = rendition.find('./src').text + except KeyError: + self._downloader.trouble('Invalid rendition field.') + return + + self._downloader.increment_downloads() + info = { + 'id': video_id, + 'url': video_url, + 'uploader': performer, + 'title': video_title, + 'stitle': simplify_title(video_title), + 'ext': ext, + 'format': format, + } + + try: + self._downloader.process_info(info) + except UnavailableVideoError, err: + self._downloader.trouble(u'\nERROR: unable to download ' + video_id) diff --git a/youtube_dl/PostProcessing.py b/youtube_dl/PostProcessing.py new file mode 100644 index 0000000000..90ac07af4d --- /dev/null +++ b/youtube_dl/PostProcessing.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import subprocess +import sys +import time + +from Utils import * + + +class PostProcessor(object): + """Post Processor class. + + PostProcessor objects can be added to downloaders with their + add_post_processor() method. When the downloader has finished a + successful download, it will take its internal chain of PostProcessors + and start calling the run() method on each one of them, first with + an initial argument and then with the returned value of the previous + PostProcessor. + + The chain will be stopped if one of them ever returns None or the end + of the chain is reached. + + PostProcessor objects follow a "mutual registration" process similar + to InfoExtractor objects. + """ + + _downloader = None + + def __init__(self, downloader=None): + self._downloader = downloader + + def set_downloader(self, downloader): + """Sets the downloader for this PP.""" + self._downloader = downloader + + def run(self, information): + """Run the PostProcessor. + + The "information" argument is a dictionary like the ones + composed by InfoExtractors. The only difference is that this + one has an extra field called "filepath" that points to the + downloaded file. + + When this method returns None, the postprocessing chain is + stopped. However, this method may return an information + dictionary that will be passed to the next postprocessing + object in the chain. It can be the one it received after + changing some fields. + + In addition, this method may raise a PostProcessingError + exception that will be taken into account by the downloader + it was called from. + """ + return information # by default, do nothing + +class AudioConversionError(BaseException): + def __init__(self, message): + self.message = message + +class FFmpegExtractAudioPP(PostProcessor): + + def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, keepvideo=False): + PostProcessor.__init__(self, downloader) + if preferredcodec is None: + preferredcodec = 'best' + self._preferredcodec = preferredcodec + self._preferredquality = preferredquality + self._keepvideo = keepvideo + + @staticmethod + def get_audio_codec(path): + try: + cmd = ['ffprobe', '-show_streams', '--', encodeFilename(path)] + handle = subprocess.Popen(cmd, stderr=file(os.path.devnull, 'w'), stdout=subprocess.PIPE) + output = handle.communicate()[0] + if handle.wait() != 0: + return None + except (IOError, OSError): + return None + audio_codec = None + for line in output.split('\n'): + if line.startswith('codec_name='): + audio_codec = line.split('=')[1].strip() + elif line.strip() == 'codec_type=audio' and audio_codec is not None: + return audio_codec + return None + + @staticmethod + def run_ffmpeg(path, out_path, codec, more_opts): + if codec is None: + acodec_opts = [] + else: + acodec_opts = ['-acodec', codec] + cmd = ['ffmpeg', '-y', '-i', encodeFilename(path), '-vn'] + acodec_opts + more_opts + ['--', encodeFilename(out_path)] + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout,stderr = p.communicate() + except (IOError, OSError): + e = sys.exc_info()[1] + if isinstance(e, OSError) and e.errno == 2: + raise AudioConversionError('ffmpeg not found. Please install ffmpeg.') + else: + raise e + if p.returncode != 0: + msg = stderr.strip().split('\n')[-1] + raise AudioConversionError(msg) + + def run(self, information): + path = information['filepath'] + + filecodec = self.get_audio_codec(path) + if filecodec is None: + self._downloader.to_stderr(u'WARNING: unable to obtain file audio codec with ffprobe') + return None + + more_opts = [] + if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'): + if self._preferredcodec == 'm4a' and filecodec == 'aac': + # Lossless, but in another container + acodec = 'copy' + extension = self._preferredcodec + more_opts = ['-absf', 'aac_adtstoasc'] + elif filecodec in ['aac', 'mp3', 'vorbis']: + # Lossless if possible + acodec = 'copy' + extension = filecodec + if filecodec == 'aac': + more_opts = ['-f', 'adts'] + if filecodec == 'vorbis': + extension = 'ogg' + else: + # MP3 otherwise. + acodec = 'libmp3lame' + extension = 'mp3' + more_opts = [] + if self._preferredquality is not None: + more_opts += ['-ab', self._preferredquality] + else: + # We convert the audio (lossy) + acodec = {'mp3': 'libmp3lame', 'aac': 'aac', 'm4a': 'aac', 'vorbis': 'libvorbis', 'wav': None}[self._preferredcodec] + extension = self._preferredcodec + more_opts = [] + if self._preferredquality is not None: + more_opts += ['-ab', self._preferredquality] + if self._preferredcodec == 'aac': + more_opts += ['-f', 'adts'] + if self._preferredcodec == 'm4a': + more_opts += ['-absf', 'aac_adtstoasc'] + if self._preferredcodec == 'vorbis': + extension = 'ogg' + if self._preferredcodec == 'wav': + extension = 'wav' + more_opts += ['-f', 'wav'] + + prefix, sep, ext = path.rpartition(u'.') # not os.path.splitext, since the latter does not work on unicode in all setups + new_path = prefix + sep + extension + self._downloader.to_screen(u'[ffmpeg] Destination: ' + new_path) + try: + self.run_ffmpeg(path, new_path, acodec, more_opts) + except: + etype,e,tb = sys.exc_info() + if isinstance(e, AudioConversionError): + self._downloader.to_stderr(u'ERROR: audio conversion failed: ' + e.message) + else: + self._downloader.to_stderr(u'ERROR: error running ffmpeg') + return None + + # Try to update the date time for extracted audio file. + if information.get('filetime') is not None: + try: + os.utime(encodeFilename(new_path), (time.time(), information['filetime'])) + except: + self._downloader.to_stderr(u'WARNING: Cannot update utime of audio file') + + if not self._keepvideo: + try: + os.remove(encodeFilename(path)) + except (IOError, OSError): + self._downloader.to_stderr(u'WARNING: Unable to remove downloaded video file') + return None + + information['filepath'] = new_path + return information diff --git a/youtube_dl/Utils.py b/youtube_dl/Utils.py new file mode 100644 index 0000000000..f924b98f7d --- /dev/null +++ b/youtube_dl/Utils.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import gzip +import htmlentitydefs +import HTMLParser +import locale +import os +import re +import sys +import zlib +import urllib2 +import email.utils + +try: + import cStringIO as StringIO +except ImportError: + import StringIO + +std_headers = { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:5.0.1) Gecko/20100101 Firefox/5.0.1', + 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Language': 'en-us,en;q=0.5', +} + +def preferredencoding(): + """Get preferred encoding. + + Returns the best encoding scheme for the system, based on + locale.getpreferredencoding() and some further tweaks. + """ + def yield_preferredencoding(): + try: + pref = locale.getpreferredencoding() + u'TEST'.encode(pref) + except: + pref = 'UTF-8' + while True: + yield pref + return yield_preferredencoding().next() + + +def htmlentity_transform(matchobj): + """Transforms an HTML entity to a Unicode character. + + This function receives a match object and is intended to be used with + the re.sub() function. + """ + entity = matchobj.group(1) + + # Known non-numeric HTML entity + if entity in htmlentitydefs.name2codepoint: + return unichr(htmlentitydefs.name2codepoint[entity]) + + # Unicode character + mobj = re.match(ur'(?u)#(x?\d+)', entity) + if mobj is not None: + numstr = mobj.group(1) + if numstr.startswith(u'x'): + base = 16 + numstr = u'0%s' % numstr + else: + base = 10 + return unichr(long(numstr, base)) + + # Unknown entity in name, return its literal representation + return (u'&%s;' % entity) + + +def sanitize_title(utitle): + """Sanitizes a video title so it could be used as part of a filename.""" + utitle = re.sub(ur'(?u)&(.+?);', htmlentity_transform, utitle) + return utitle.replace(unicode(os.sep), u'%') + + +def sanitize_open(filename, open_mode): + """Try to open the given filename, and slightly tweak it if this fails. + + Attempts to open the given filename. If this fails, it tries to change + the filename slightly, step by step, until it's either able to open it + or it fails and raises a final exception, like the standard open() + function. + + It returns the tuple (stream, definitive_file_name). + """ + try: + if filename == u'-': + if sys.platform == 'win32': + import msvcrt + msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) + return (sys.stdout, filename) + stream = open(encodeFilename(filename), open_mode) + return (stream, filename) + except (IOError, OSError), err: + # In case of error, try to remove win32 forbidden chars + filename = re.sub(ur'[/<>:"\|\?\*]', u'#', filename) + + # An exception here should be caught in the caller + stream = open(encodeFilename(filename), open_mode) + return (stream, filename) + + +def timeconvert(timestr): + """Convert RFC 2822 defined time string into system timestamp""" + timestamp = None + timetuple = email.utils.parsedate_tz(timestr) + if timetuple is not None: + timestamp = email.utils.mktime_tz(timetuple) + return timestamp + +def simplify_title(title): + expr = re.compile(ur'[^\w\d_\-]+', flags=re.UNICODE) + return expr.sub(u'_', title).strip(u'_') + +def orderedSet(iterable): + """ Remove all duplicates from the input iterable """ + res = [] + for el in iterable: + if el not in res: + res.append(el) + return res + +def unescapeHTML(s): + """ + @param s a string (of type unicode) + """ + assert type(s) == type(u'') + + htmlParser = HTMLParser.HTMLParser() + return htmlParser.unescape(s) + +def encodeFilename(s): + """ + @param s The name of the file (of type unicode) + """ + + assert type(s) == type(u'') + + if sys.platform == 'win32' and sys.getwindowsversion().major >= 5: + # Pass u'' directly to use Unicode APIs on Windows 2000 and up + # (Detecting Windows NT 4 is tricky because 'major >= 4' would + # match Windows 9x series as well. Besides, NT 4 is obsolete.) + return s + else: + return s.encode(sys.getfilesystemencoding(), 'ignore') + +class DownloadError(Exception): + """Download Error exception. + + This exception may be thrown by FileDownloader objects if they are not + configured to continue on errors. They will contain the appropriate + error message. + """ + pass + + +class SameFileError(Exception): + """Same File exception. + + This exception will be thrown by FileDownloader objects if they detect + multiple files would have to be downloaded to the same file on disk. + """ + pass + + +class PostProcessingError(Exception): + """Post Processing exception. + + This exception may be raised by PostProcessor's .run() method to + indicate an error in the postprocessing task. + """ + pass + +class MaxDownloadsReached(Exception): + """ --max-downloads limit has been reached. """ + pass + + +class UnavailableVideoError(Exception): + """Unavailable Format exception. + + This exception will be thrown when a video is requested + in a format that is not available for that video. + """ + pass + + +class ContentTooShortError(Exception): + """Content Too Short exception. + + This exception may be raised by FileDownloader objects when a file they + download is too small for what the server announced first, indicating + the connection was probably interrupted. + """ + # Both in bytes + downloaded = None + expected = None + + def __init__(self, downloaded, expected): + self.downloaded = downloaded + self.expected = expected + + +class YoutubeDLHandler(urllib2.HTTPHandler): + """Handler for HTTP requests and responses. + + This class, when installed with an OpenerDirector, automatically adds + the standard headers to every HTTP request and handles gzipped and + deflated responses from web servers. If compression is to be avoided in + a particular request, the original request in the program code only has + to include the HTTP header "Youtubedl-No-Compression", which will be + removed before making the real request. + + Part of this code was copied from: + + http://techknack.net/python-urllib2-handlers/ + + Andrew Rowls, the author of that code, agreed to release it to the + public domain. + """ + + @staticmethod + def deflate(data): + try: + return zlib.decompress(data, -zlib.MAX_WBITS) + except zlib.error: + return zlib.decompress(data) + + @staticmethod + def addinfourl_wrapper(stream, headers, url, code): + if hasattr(urllib2.addinfourl, 'getcode'): + return urllib2.addinfourl(stream, headers, url, code) + ret = urllib2.addinfourl(stream, headers, url) + ret.code = code + return ret + + def http_request(self, req): + for h in std_headers: + if h in req.headers: + del req.headers[h] + req.add_header(h, std_headers[h]) + if 'Youtubedl-no-compression' in req.headers: + if 'Accept-encoding' in req.headers: + del req.headers['Accept-encoding'] + del req.headers['Youtubedl-no-compression'] + return req + + def http_response(self, req, resp): + old_resp = resp + # gzip + if resp.headers.get('Content-encoding', '') == 'gzip': + gz = gzip.GzipFile(fileobj=StringIO.StringIO(resp.read()), mode='r') + resp = self.addinfourl_wrapper(gz, old_resp.headers, old_resp.url, old_resp.code) + resp.msg = old_resp.msg + # deflate + if resp.headers.get('Content-encoding', '') == 'deflate': + gz = StringIO.StringIO(self.deflate(resp.read())) + resp = self.addinfourl_wrapper(gz, old_resp.headers, old_resp.url, old_resp.code) + resp.msg = old_resp.msg + return resp + +try: + import json +except ImportError: # Python <2.6, use trivialjson (https://github.com/phihag/trivialjson): + import re + class json(object): + @staticmethod + def loads(s): + s = s.decode('UTF-8') + def raiseError(msg, i): + raise ValueError(msg + ' at position ' + str(i) + ' of ' + repr(s) + ': ' + repr(s[i:])) + def skipSpace(i, expectMore=True): + while i < len(s) and s[i] in ' \t\r\n': + i += 1 + if expectMore: + if i >= len(s): + raiseError('Premature end', i) + return i + def decodeEscape(match): + esc = match.group(1) + _STATIC = { + '"': '"', + '\\': '\\', + '/': '/', + 'b': unichr(0x8), + 'f': unichr(0xc), + 'n': '\n', + 'r': '\r', + 't': '\t', + } + if esc in _STATIC: + return _STATIC[esc] + if esc[0] == 'u': + if len(esc) == 1+4: + return unichr(int(esc[1:5], 16)) + if len(esc) == 5+6 and esc[5:7] == '\\u': + hi = int(esc[1:5], 16) + low = int(esc[7:11], 16) + return unichr((hi - 0xd800) * 0x400 + low - 0xdc00 + 0x10000) + raise ValueError('Unknown escape ' + str(esc)) + def parseString(i): + i += 1 + e = i + while True: + e = s.index('"', e) + bslashes = 0 + while s[e-bslashes-1] == '\\': + bslashes += 1 + if bslashes % 2 == 1: + e += 1 + continue + break + rexp = re.compile(r'\\(u[dD][89aAbB][0-9a-fA-F]{2}\\u[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.|$)') + stri = rexp.sub(decodeEscape, s[i:e]) + return (e+1,stri) + def parseObj(i): + i += 1 + res = {} + i = skipSpace(i) + if s[i] == '}': # Empty dictionary + return (i+1,res) + while True: + if s[i] != '"': + raiseError('Expected a string object key', i) + i,key = parseString(i) + i = skipSpace(i) + if i >= len(s) or s[i] != ':': + raiseError('Expected a colon', i) + i,val = parse(i+1) + res[key] = val + i = skipSpace(i) + if s[i] == '}': + return (i+1, res) + if s[i] != ',': + raiseError('Expected comma or closing curly brace', i) + i = skipSpace(i+1) + def parseArray(i): + res = [] + i = skipSpace(i+1) + if s[i] == ']': # Empty array + return (i+1,res) + while True: + i,val = parse(i) + res.append(val) + i = skipSpace(i) # Raise exception if premature end + if s[i] == ']': + return (i+1, res) + if s[i] != ',': + raiseError('Expected a comma or closing bracket', i) + i = skipSpace(i+1) + def parseDiscrete(i): + for k,v in {'true': True, 'false': False, 'null': None}.items(): + if s.startswith(k, i): + return (i+len(k), v) + raiseError('Not a boolean (or null)', i) + def parseNumber(i): + mobj = re.match('^(-?(0|[1-9][0-9]*)(\.[0-9]*)?([eE][+-]?[0-9]+)?)', s[i:]) + if mobj is None: + raiseError('Not a number', i) + nums = mobj.group(1) + if '.' in nums or 'e' in nums or 'E' in nums: + return (i+len(nums), float(nums)) + return (i+len(nums), int(nums)) + CHARMAP = {'{': parseObj, '[': parseArray, '"': parseString, 't': parseDiscrete, 'f': parseDiscrete, 'n': parseDiscrete} + def parse(i): + i = skipSpace(i) + i,res = CHARMAP.get(s[i], parseNumber)(i) + i = skipSpace(i, False) + return (i,res) + i,res = parse(0) + if i < len(s): + raise ValueError('Extra data at end of input (index ' + str(i) + ' of ' + repr(s) + ': ' + repr(s[i:]) + ')') + return res diff --git a/youtube_dl/__init__.py b/youtube_dl/__init__.py old mode 100755 new mode 100644 index 8d0d1cc338..409e4386f7 --- a/youtube_dl/__init__.py +++ b/youtube_dl/__init__.py @@ -25,4294 +25,21 @@ UPDATE_URL = 'https://raw.github.com/rg3/youtube-dl/master/youtube-dl' import cookielib -import datetime import getpass -import gzip -import htmlentitydefs -import HTMLParser -import httplib -import locale -import math -import netrc import optparse import os -import os.path import re import shlex import socket -import string import subprocess import sys -import time -import urllib import urllib2 import warnings -import zlib - -if os.name == 'nt': - import ctypes - -try: - import email.utils -except ImportError: # Python 2.4 - import email.Utils -try: - import cStringIO as StringIO -except ImportError: - import StringIO - -# parse_qs was moved from the cgi module to the urlparse module recently. -try: - from urlparse import parse_qs -except ImportError: - from cgi import parse_qs - -try: - import lxml.etree -except ImportError: - pass # Handled below - -try: - import xml.etree.ElementTree -except ImportError: # Python<2.5: Not officially supported, but let it slip - warnings.warn('xml.etree.ElementTree support is missing. Consider upgrading to Python >= 2.5 if you get related errors.') - -std_headers = { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:5.0.1) Gecko/20100101 Firefox/5.0.1', - 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Encoding': 'gzip, deflate', - 'Accept-Language': 'en-us,en;q=0.5', -} - -try: - import json -except ImportError: # Python <2.6, use trivialjson (https://github.com/phihag/trivialjson): - import re - class json(object): - @staticmethod - def loads(s): - s = s.decode('UTF-8') - def raiseError(msg, i): - raise ValueError(msg + ' at position ' + str(i) + ' of ' + repr(s) + ': ' + repr(s[i:])) - def skipSpace(i, expectMore=True): - while i < len(s) and s[i] in ' \t\r\n': - i += 1 - if expectMore: - if i >= len(s): - raiseError('Premature end', i) - return i - def decodeEscape(match): - esc = match.group(1) - _STATIC = { - '"': '"', - '\\': '\\', - '/': '/', - 'b': unichr(0x8), - 'f': unichr(0xc), - 'n': '\n', - 'r': '\r', - 't': '\t', - } - if esc in _STATIC: - return _STATIC[esc] - if esc[0] == 'u': - if len(esc) == 1+4: - return unichr(int(esc[1:5], 16)) - if len(esc) == 5+6 and esc[5:7] == '\\u': - hi = int(esc[1:5], 16) - low = int(esc[7:11], 16) - return unichr((hi - 0xd800) * 0x400 + low - 0xdc00 + 0x10000) - raise ValueError('Unknown escape ' + str(esc)) - def parseString(i): - i += 1 - e = i - while True: - e = s.index('"', e) - bslashes = 0 - while s[e-bslashes-1] == '\\': - bslashes += 1 - if bslashes % 2 == 1: - e += 1 - continue - break - rexp = re.compile(r'\\(u[dD][89aAbB][0-9a-fA-F]{2}\\u[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.|$)') - stri = rexp.sub(decodeEscape, s[i:e]) - return (e+1,stri) - def parseObj(i): - i += 1 - res = {} - i = skipSpace(i) - if s[i] == '}': # Empty dictionary - return (i+1,res) - while True: - if s[i] != '"': - raiseError('Expected a string object key', i) - i,key = parseString(i) - i = skipSpace(i) - if i >= len(s) or s[i] != ':': - raiseError('Expected a colon', i) - i,val = parse(i+1) - res[key] = val - i = skipSpace(i) - if s[i] == '}': - return (i+1, res) - if s[i] != ',': - raiseError('Expected comma or closing curly brace', i) - i = skipSpace(i+1) - def parseArray(i): - res = [] - i = skipSpace(i+1) - if s[i] == ']': # Empty array - return (i+1,res) - while True: - i,val = parse(i) - res.append(val) - i = skipSpace(i) # Raise exception if premature end - if s[i] == ']': - return (i+1, res) - if s[i] != ',': - raiseError('Expected a comma or closing bracket', i) - i = skipSpace(i+1) - def parseDiscrete(i): - for k,v in {'true': True, 'false': False, 'null': None}.items(): - if s.startswith(k, i): - return (i+len(k), v) - raiseError('Not a boolean (or null)', i) - def parseNumber(i): - mobj = re.match('^(-?(0|[1-9][0-9]*)(\.[0-9]*)?([eE][+-]?[0-9]+)?)', s[i:]) - if mobj is None: - raiseError('Not a number', i) - nums = mobj.group(1) - if '.' in nums or 'e' in nums or 'E' in nums: - return (i+len(nums), float(nums)) - return (i+len(nums), int(nums)) - CHARMAP = {'{': parseObj, '[': parseArray, '"': parseString, 't': parseDiscrete, 'f': parseDiscrete, 'n': parseDiscrete} - def parse(i): - i = skipSpace(i) - i,res = CHARMAP.get(s[i], parseNumber)(i) - i = skipSpace(i, False) - return (i,res) - i,res = parse(0) - if i < len(s): - raise ValueError('Extra data at end of input (index ' + str(i) + ' of ' + repr(s) + ': ' + repr(s[i:]) + ')') - return res - -def preferredencoding(): - """Get preferred encoding. - - Returns the best encoding scheme for the system, based on - locale.getpreferredencoding() and some further tweaks. - """ - def yield_preferredencoding(): - try: - pref = locale.getpreferredencoding() - u'TEST'.encode(pref) - except: - pref = 'UTF-8' - while True: - yield pref - return yield_preferredencoding().next() - - -def htmlentity_transform(matchobj): - """Transforms an HTML entity to a Unicode character. - - This function receives a match object and is intended to be used with - the re.sub() function. - """ - entity = matchobj.group(1) - - # Known non-numeric HTML entity - if entity in htmlentitydefs.name2codepoint: - return unichr(htmlentitydefs.name2codepoint[entity]) - - # Unicode character - mobj = re.match(ur'(?u)#(x?\d+)', entity) - if mobj is not None: - numstr = mobj.group(1) - if numstr.startswith(u'x'): - base = 16 - numstr = u'0%s' % numstr - else: - base = 10 - return unichr(long(numstr, base)) - - # Unknown entity in name, return its literal representation - return (u'&%s;' % entity) - - -def sanitize_title(utitle): - """Sanitizes a video title so it could be used as part of a filename.""" - utitle = re.sub(ur'(?u)&(.+?);', htmlentity_transform, utitle) - return utitle.replace(unicode(os.sep), u'%') - - -def sanitize_open(filename, open_mode): - """Try to open the given filename, and slightly tweak it if this fails. - - Attempts to open the given filename. If this fails, it tries to change - the filename slightly, step by step, until it's either able to open it - or it fails and raises a final exception, like the standard open() - function. - - It returns the tuple (stream, definitive_file_name). - """ - try: - if filename == u'-': - if sys.platform == 'win32': - import msvcrt - msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) - return (sys.stdout, filename) - stream = open(_encodeFilename(filename), open_mode) - return (stream, filename) - except (IOError, OSError), err: - # In case of error, try to remove win32 forbidden chars - filename = re.sub(ur'[/<>:"\|\?\*]', u'#', filename) - - # An exception here should be caught in the caller - stream = open(_encodeFilename(filename), open_mode) - return (stream, filename) - - -def timeconvert(timestr): - """Convert RFC 2822 defined time string into system timestamp""" - timestamp = None - timetuple = email.utils.parsedate_tz(timestr) - if timetuple is not None: - timestamp = email.utils.mktime_tz(timetuple) - return timestamp - -def _simplify_title(title): - expr = re.compile(ur'[^\w\d_\-]+', flags=re.UNICODE) - return expr.sub(u'_', title).strip(u'_') - -def _orderedSet(iterable): - """ Remove all duplicates from the input iterable """ - res = [] - for el in iterable: - if el not in res: - res.append(el) - return res - -def _unescapeHTML(s): - """ - @param s a string (of type unicode) - """ - assert type(s) == type(u'') - - htmlParser = HTMLParser.HTMLParser() - return htmlParser.unescape(s) - -def _encodeFilename(s): - """ - @param s The name of the file (of type unicode) - """ - - assert type(s) == type(u'') - - if sys.platform == 'win32' and sys.getwindowsversion().major >= 5: - # Pass u'' directly to use Unicode APIs on Windows 2000 and up - # (Detecting Windows NT 4 is tricky because 'major >= 4' would - # match Windows 9x series as well. Besides, NT 4 is obsolete.) - return s - else: - return s.encode(sys.getfilesystemencoding(), 'ignore') - -class DownloadError(Exception): - """Download Error exception. - - This exception may be thrown by FileDownloader objects if they are not - configured to continue on errors. They will contain the appropriate - error message. - """ - pass - - -class SameFileError(Exception): - """Same File exception. - - This exception will be thrown by FileDownloader objects if they detect - multiple files would have to be downloaded to the same file on disk. - """ - pass - - -class PostProcessingError(Exception): - """Post Processing exception. - - This exception may be raised by PostProcessor's .run() method to - indicate an error in the postprocessing task. - """ - pass - -class MaxDownloadsReached(Exception): - """ --max-downloads limit has been reached. """ - pass - - -class UnavailableVideoError(Exception): - """Unavailable Format exception. - - This exception will be thrown when a video is requested - in a format that is not available for that video. - """ - pass - - -class ContentTooShortError(Exception): - """Content Too Short exception. - - This exception may be raised by FileDownloader objects when a file they - download is too small for what the server announced first, indicating - the connection was probably interrupted. - """ - # Both in bytes - downloaded = None - expected = None - - def __init__(self, downloaded, expected): - self.downloaded = downloaded - self.expected = expected - - -class YoutubeDLHandler(urllib2.HTTPHandler): - """Handler for HTTP requests and responses. - - This class, when installed with an OpenerDirector, automatically adds - the standard headers to every HTTP request and handles gzipped and - deflated responses from web servers. If compression is to be avoided in - a particular request, the original request in the program code only has - to include the HTTP header "Youtubedl-No-Compression", which will be - removed before making the real request. - - Part of this code was copied from: - - http://techknack.net/python-urllib2-handlers/ - - Andrew Rowls, the author of that code, agreed to release it to the - public domain. - """ - - @staticmethod - def deflate(data): - try: - return zlib.decompress(data, -zlib.MAX_WBITS) - except zlib.error: - return zlib.decompress(data) - - @staticmethod - def addinfourl_wrapper(stream, headers, url, code): - if hasattr(urllib2.addinfourl, 'getcode'): - return urllib2.addinfourl(stream, headers, url, code) - ret = urllib2.addinfourl(stream, headers, url) - ret.code = code - return ret - - def http_request(self, req): - for h in std_headers: - if h in req.headers: - del req.headers[h] - req.add_header(h, std_headers[h]) - if 'Youtubedl-no-compression' in req.headers: - if 'Accept-encoding' in req.headers: - del req.headers['Accept-encoding'] - del req.headers['Youtubedl-no-compression'] - return req - - def http_response(self, req, resp): - old_resp = resp - # gzip - if resp.headers.get('Content-encoding', '') == 'gzip': - gz = gzip.GzipFile(fileobj=StringIO.StringIO(resp.read()), mode='r') - resp = self.addinfourl_wrapper(gz, old_resp.headers, old_resp.url, old_resp.code) - resp.msg = old_resp.msg - # deflate - if resp.headers.get('Content-encoding', '') == 'deflate': - gz = StringIO.StringIO(self.deflate(resp.read())) - resp = self.addinfourl_wrapper(gz, old_resp.headers, old_resp.url, old_resp.code) - resp.msg = old_resp.msg - return resp - - -class FileDownloader(object): - """File Downloader class. - - File downloader objects are the ones responsible of downloading the - actual video file and writing it to disk if the user has requested - it, among some other tasks. In most cases there should be one per - program. As, given a video URL, the downloader doesn't know how to - extract all the needed information, task that InfoExtractors do, it - has to pass the URL to one of them. - - For this, file downloader objects have a method that allows - InfoExtractors to be registered in a given order. When it is passed - a URL, the file downloader handles it to the first InfoExtractor it - finds that reports being able to handle it. The InfoExtractor extracts - all the information about the video or videos the URL refers to, and - asks the FileDownloader to process the video information, possibly - downloading the video. - - File downloaders accept a lot of parameters. In order not to saturate - the object constructor with arguments, it receives a dictionary of - options instead. These options are available through the params - attribute for the InfoExtractors to use. The FileDownloader also - registers itself as the downloader in charge for the InfoExtractors - that are added to it, so this is a "mutual registration". - - Available options: - - username: Username for authentication purposes. - password: Password for authentication purposes. - usenetrc: Use netrc for authentication instead. - quiet: Do not print messages to stdout. - forceurl: Force printing final URL. - forcetitle: Force printing title. - forcethumbnail: Force printing thumbnail URL. - forcedescription: Force printing description. - forcefilename: Force printing final filename. - simulate: Do not download the video files. - format: Video format code. - format_limit: Highest quality format to try. - outtmpl: Template for output names. - ignoreerrors: Do not stop on download errors. - ratelimit: Download speed limit, in bytes/sec. - nooverwrites: Prevent overwriting files. - retries: Number of times to retry for HTTP error 5xx - continuedl: Try to continue downloads if possible. - noprogress: Do not print the progress bar. - playliststart: Playlist item to start at. - playlistend: Playlist item to end at. - matchtitle: Download only matching titles. - rejecttitle: Reject downloads for matching titles. - logtostderr: Log messages to stderr instead of stdout. - consoletitle: Display progress in console window's titlebar. - nopart: Do not use temporary .part files. - updatetime: Use the Last-modified header to set output file timestamps. - writedescription: Write the video description to a .description file - writeinfojson: Write the video description to a .info.json file - writesubtitles: Write the video subtitles to a .srt file - subtitleslang: Language of the subtitles to download - """ - - params = None - _ies = [] - _pps = [] - _download_retcode = None - _num_downloads = None - _screen_file = None - - def __init__(self, params): - """Create a FileDownloader object with the given options.""" - self._ies = [] - self._pps = [] - self._download_retcode = 0 - self._num_downloads = 0 - self._screen_file = [sys.stdout, sys.stderr][params.get('logtostderr', False)] - self.params = params - - @staticmethod - def format_bytes(bytes): - if bytes is None: - return 'N/A' - if type(bytes) is str: - bytes = float(bytes) - if bytes == 0.0: - exponent = 0 - else: - exponent = long(math.log(bytes, 1024.0)) - suffix = 'bkMGTPEZY'[exponent] - converted = float(bytes) / float(1024 ** exponent) - return '%.2f%s' % (converted, suffix) - - @staticmethod - def calc_percent(byte_counter, data_len): - if data_len is None: - return '---.-%' - return '%6s' % ('%3.1f%%' % (float(byte_counter) / float(data_len) * 100.0)) - - @staticmethod - def calc_eta(start, now, total, current): - if total is None: - return '--:--' - dif = now - start - if current == 0 or dif < 0.001: # One millisecond - return '--:--' - rate = float(current) / dif - eta = long((float(total) - float(current)) / rate) - (eta_mins, eta_secs) = divmod(eta, 60) - if eta_mins > 99: - return '--:--' - return '%02d:%02d' % (eta_mins, eta_secs) - - @staticmethod - def calc_speed(start, now, bytes): - dif = now - start - if bytes == 0 or dif < 0.001: # One millisecond - return '%10s' % '---b/s' - return '%10s' % ('%s/s' % FileDownloader.format_bytes(float(bytes) / dif)) - - @staticmethod - def best_block_size(elapsed_time, bytes): - new_min = max(bytes / 2.0, 1.0) - new_max = min(max(bytes * 2.0, 1.0), 4194304) # Do not surpass 4 MB - if elapsed_time < 0.001: - return long(new_max) - rate = bytes / elapsed_time - if rate > new_max: - return long(new_max) - if rate < new_min: - return long(new_min) - return long(rate) - - @staticmethod - def parse_bytes(bytestr): - """Parse a string indicating a byte quantity into a long integer.""" - matchobj = re.match(r'(?i)^(\d+(?:\.\d+)?)([kMGTPEZY]?)$', bytestr) - if matchobj is None: - return None - number = float(matchobj.group(1)) - multiplier = 1024.0 ** 'bkmgtpezy'.index(matchobj.group(2).lower()) - return long(round(number * multiplier)) - - def add_info_extractor(self, ie): - """Add an InfoExtractor object to the end of the list.""" - self._ies.append(ie) - ie.set_downloader(self) - - def add_post_processor(self, pp): - """Add a PostProcessor object to the end of the chain.""" - self._pps.append(pp) - pp.set_downloader(self) - - def to_screen(self, message, skip_eol=False): - """Print message to stdout if not in quiet mode.""" - assert type(message) == type(u'') - if not self.params.get('quiet', False): - terminator = [u'\n', u''][skip_eol] - output = message + terminator - - if 'b' not in self._screen_file.mode or sys.version_info[0] < 3: # Python 2 lies about the mode of sys.stdout/sys.stderr - output = output.encode(preferredencoding(), 'ignore') - self._screen_file.write(output) - self._screen_file.flush() - - def to_stderr(self, message): - """Print message to stderr.""" - print >>sys.stderr, message.encode(preferredencoding()) - - def to_cons_title(self, message): - """Set console/terminal window title to message.""" - if not self.params.get('consoletitle', False): - return - if os.name == 'nt' and ctypes.windll.kernel32.GetConsoleWindow(): - # c_wchar_p() might not be necessary if `message` is - # already of type unicode() - ctypes.windll.kernel32.SetConsoleTitleW(ctypes.c_wchar_p(message)) - elif 'TERM' in os.environ: - sys.stderr.write('\033]0;%s\007' % message.encode(preferredencoding())) - - def fixed_template(self): - """Checks if the output template is fixed.""" - return (re.search(ur'(?u)%\(.+?\)s', self.params['outtmpl']) is None) - - def trouble(self, message=None): - """Determine action to take when a download problem appears. - - Depending on if the downloader has been configured to ignore - download errors or not, this method may throw an exception or - not when errors are found, after printing the message. - """ - if message is not None: - self.to_stderr(message) - if not self.params.get('ignoreerrors', False): - raise DownloadError(message) - self._download_retcode = 1 - - def slow_down(self, start_time, byte_counter): - """Sleep if the download speed is over the rate limit.""" - rate_limit = self.params.get('ratelimit', None) - if rate_limit is None or byte_counter == 0: - return - now = time.time() - elapsed = now - start_time - if elapsed <= 0.0: - return - speed = float(byte_counter) / elapsed - if speed > rate_limit: - time.sleep((byte_counter - rate_limit * (now - start_time)) / rate_limit) - - def temp_name(self, filename): - """Returns a temporary filename for the given filename.""" - if self.params.get('nopart', False) or filename == u'-' or \ - (os.path.exists(_encodeFilename(filename)) and not os.path.isfile(_encodeFilename(filename))): - return filename - return filename + u'.part' - - def undo_temp_name(self, filename): - if filename.endswith(u'.part'): - return filename[:-len(u'.part')] - return filename - - def try_rename(self, old_filename, new_filename): - try: - if old_filename == new_filename: - return - os.rename(_encodeFilename(old_filename), _encodeFilename(new_filename)) - except (IOError, OSError), err: - self.trouble(u'ERROR: unable to rename file') - - def try_utime(self, filename, last_modified_hdr): - """Try to set the last-modified time of the given file.""" - if last_modified_hdr is None: - return - if not os.path.isfile(_encodeFilename(filename)): - return - timestr = last_modified_hdr - if timestr is None: - return - filetime = timeconvert(timestr) - if filetime is None: - return filetime - try: - os.utime(filename, (time.time(), filetime)) - except: - pass - return filetime - - def report_writedescription(self, descfn): - """ Report that the description file is being written """ - self.to_screen(u'[info] Writing video description to: ' + descfn) - - def report_writesubtitles(self, srtfn): - """ Report that the subtitles file is being written """ - self.to_screen(u'[info] Writing video subtitles to: ' + srtfn) - - def report_writeinfojson(self, infofn): - """ Report that the metadata file has been written """ - self.to_screen(u'[info] Video description metadata as JSON to: ' + infofn) - - def report_destination(self, filename): - """Report destination filename.""" - self.to_screen(u'[download] Destination: ' + filename) - - def report_progress(self, percent_str, data_len_str, speed_str, eta_str): - """Report download progress.""" - if self.params.get('noprogress', False): - return - self.to_screen(u'\r[download] %s of %s at %s ETA %s' % - (percent_str, data_len_str, speed_str, eta_str), skip_eol=True) - self.to_cons_title(u'youtube-dl - %s of %s at %s ETA %s' % - (percent_str.strip(), data_len_str.strip(), speed_str.strip(), eta_str.strip())) - - def report_resuming_byte(self, resume_len): - """Report attempt to resume at given byte.""" - self.to_screen(u'[download] Resuming download at byte %s' % resume_len) - - def report_retry(self, count, retries): - """Report retry in case of HTTP error 5xx""" - self.to_screen(u'[download] Got server HTTP error. Retrying (attempt %d of %d)...' % (count, retries)) - - def report_file_already_downloaded(self, file_name): - """Report file has already been fully downloaded.""" - try: - self.to_screen(u'[download] %s has already been downloaded' % file_name) - except (UnicodeEncodeError), err: - self.to_screen(u'[download] The file has already been downloaded') - - def report_unable_to_resume(self): - """Report it was impossible to resume download.""" - self.to_screen(u'[download] Unable to resume') - - def report_finish(self): - """Report download finished.""" - if self.params.get('noprogress', False): - self.to_screen(u'[download] Download completed') - else: - self.to_screen(u'') - - def increment_downloads(self): - """Increment the ordinal that assigns a number to each file.""" - self._num_downloads += 1 - - def prepare_filename(self, info_dict): - """Generate the output filename.""" - try: - template_dict = dict(info_dict) - template_dict['epoch'] = unicode(long(time.time())) - template_dict['autonumber'] = unicode('%05d' % self._num_downloads) - filename = self.params['outtmpl'] % template_dict - return filename - except (ValueError, KeyError), err: - self.trouble(u'ERROR: invalid system charset or erroneous output template') - return None - - def _match_entry(self, info_dict): - """ Returns None iff the file should be downloaded """ - - title = info_dict['title'] - matchtitle = self.params.get('matchtitle', False) - if matchtitle and not re.search(matchtitle, title, re.IGNORECASE): - return u'[download] "' + title + '" title did not match pattern "' + matchtitle + '"' - rejecttitle = self.params.get('rejecttitle', False) - if rejecttitle and re.search(rejecttitle, title, re.IGNORECASE): - return u'"' + title + '" title matched reject pattern "' + rejecttitle + '"' - return None - - def process_info(self, info_dict): - """Process a single dictionary returned by an InfoExtractor.""" - - reason = self._match_entry(info_dict) - if reason is not None: - self.to_screen(u'[download] ' + reason) - return - - max_downloads = self.params.get('max_downloads') - if max_downloads is not None: - if self._num_downloads > int(max_downloads): - raise MaxDownloadsReached() - - filename = self.prepare_filename(info_dict) - - # Forced printings - if self.params.get('forcetitle', False): - print info_dict['title'].encode(preferredencoding(), 'xmlcharrefreplace') - if self.params.get('forceurl', False): - print info_dict['url'].encode(preferredencoding(), 'xmlcharrefreplace') - if self.params.get('forcethumbnail', False) and 'thumbnail' in info_dict: - print info_dict['thumbnail'].encode(preferredencoding(), 'xmlcharrefreplace') - if self.params.get('forcedescription', False) and 'description' in info_dict: - print info_dict['description'].encode(preferredencoding(), 'xmlcharrefreplace') - if self.params.get('forcefilename', False) and filename is not None: - print filename.encode(preferredencoding(), 'xmlcharrefreplace') - if self.params.get('forceformat', False): - print info_dict['format'].encode(preferredencoding(), 'xmlcharrefreplace') - - # Do nothing else if in simulate mode - if self.params.get('simulate', False): - return - - if filename is None: - return - - try: - dn = os.path.dirname(_encodeFilename(filename)) - if dn != '' and not os.path.exists(dn): # dn is already encoded - os.makedirs(dn) - except (OSError, IOError), err: - self.trouble(u'ERROR: unable to create directory ' + unicode(err)) - return - - if self.params.get('writedescription', False): - try: - descfn = filename + u'.description' - self.report_writedescription(descfn) - descfile = open(_encodeFilename(descfn), 'wb') - try: - descfile.write(info_dict['description'].encode('utf-8')) - finally: - descfile.close() - except (OSError, IOError): - self.trouble(u'ERROR: Cannot write description file ' + descfn) - return - - if self.params.get('writesubtitles', False) and 'subtitles' in info_dict and info_dict['subtitles']: - # subtitles download errors are already managed as troubles in relevant IE - # that way it will silently go on when used with unsupporting IE - try: - srtfn = filename.rsplit('.', 1)[0] + u'.srt' - self.report_writesubtitles(srtfn) - srtfile = open(_encodeFilename(srtfn), 'wb') - try: - srtfile.write(info_dict['subtitles'].encode('utf-8')) - finally: - srtfile.close() - except (OSError, IOError): - self.trouble(u'ERROR: Cannot write subtitles file ' + descfn) - return - - if self.params.get('writeinfojson', False): - infofn = filename + u'.info.json' - self.report_writeinfojson(infofn) - try: - json.dump - except (NameError,AttributeError): - self.trouble(u'ERROR: No JSON encoder found. Update to Python 2.6+, setup a json module, or leave out --write-info-json.') - return - try: - infof = open(_encodeFilename(infofn), 'wb') - try: - json_info_dict = dict((k,v) for k,v in info_dict.iteritems() if not k in ('urlhandle',)) - json.dump(json_info_dict, infof) - finally: - infof.close() - except (OSError, IOError): - self.trouble(u'ERROR: Cannot write metadata to JSON file ' + infofn) - return - - if not self.params.get('skip_download', False): - if self.params.get('nooverwrites', False) and os.path.exists(_encodeFilename(filename)): - success = True - else: - try: - success = self._do_download(filename, info_dict) - except (OSError, IOError), err: - raise UnavailableVideoError - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self.trouble(u'ERROR: unable to download video data: %s' % str(err)) - return - except (ContentTooShortError, ), err: - self.trouble(u'ERROR: content too short (expected %s bytes and served %s)' % (err.expected, err.downloaded)) - return - - if success: - try: - self.post_process(filename, info_dict) - except (PostProcessingError), err: - self.trouble(u'ERROR: postprocessing: %s' % str(err)) - return - - def download(self, url_list): - """Download a given list of URLs.""" - if len(url_list) > 1 and self.fixed_template(): - raise SameFileError(self.params['outtmpl']) - - for url in url_list: - suitable_found = False - for ie in self._ies: - # Go to next InfoExtractor if not suitable - if not ie.suitable(url): - continue - - # Suitable InfoExtractor found - suitable_found = True - - # Extract information from URL and process it - ie.extract(url) - - # Suitable InfoExtractor had been found; go to next URL - break - - if not suitable_found: - self.trouble(u'ERROR: no suitable InfoExtractor: %s' % url) - - return self._download_retcode - - def post_process(self, filename, ie_info): - """Run the postprocessing chain on the given file.""" - info = dict(ie_info) - info['filepath'] = filename - for pp in self._pps: - info = pp.run(info) - if info is None: - break - - def _download_with_rtmpdump(self, filename, url, player_url): - self.report_destination(filename) - tmpfilename = self.temp_name(filename) - - # Check for rtmpdump first - try: - subprocess.call(['rtmpdump', '-h'], stdout=(file(os.path.devnull, 'w')), stderr=subprocess.STDOUT) - except (OSError, IOError): - self.trouble(u'ERROR: RTMP download detected but "rtmpdump" could not be run') - return False - - # Download using rtmpdump. rtmpdump returns exit code 2 when - # the connection was interrumpted and resuming appears to be - # possible. This is part of rtmpdump's normal usage, AFAIK. - basic_args = ['rtmpdump', '-q'] + [[], ['-W', player_url]][player_url is not None] + ['-r', url, '-o', tmpfilename] - args = basic_args + [[], ['-e', '-k', '1']][self.params.get('continuedl', False)] - if self.params.get('verbose', False): - try: - import pipes - shell_quote = lambda args: ' '.join(map(pipes.quote, args)) - except ImportError: - shell_quote = repr - self.to_screen(u'[debug] rtmpdump command line: ' + shell_quote(args)) - retval = subprocess.call(args) - while retval == 2 or retval == 1: - prevsize = os.path.getsize(_encodeFilename(tmpfilename)) - self.to_screen(u'\r[rtmpdump] %s bytes' % prevsize, skip_eol=True) - time.sleep(5.0) # This seems to be needed - retval = subprocess.call(basic_args + ['-e'] + [[], ['-k', '1']][retval == 1]) - cursize = os.path.getsize(_encodeFilename(tmpfilename)) - if prevsize == cursize and retval == 1: - break - # Some rtmp streams seem abort after ~ 99.8%. Don't complain for those - if prevsize == cursize and retval == 2 and cursize > 1024: - self.to_screen(u'\r[rtmpdump] Could not download the whole video. This can happen for some advertisements.') - retval = 0 - break - if retval == 0: - self.to_screen(u'\r[rtmpdump] %s bytes' % os.path.getsize(_encodeFilename(tmpfilename))) - self.try_rename(tmpfilename, filename) - return True - else: - self.trouble(u'\nERROR: rtmpdump exited with code %d' % retval) - return False - - def _do_download(self, filename, info_dict): - url = info_dict['url'] - player_url = info_dict.get('player_url', None) - - # Check file already present - if self.params.get('continuedl', False) and os.path.isfile(_encodeFilename(filename)) and not self.params.get('nopart', False): - self.report_file_already_downloaded(filename) - return True - - # Attempt to download using rtmpdump - if url.startswith('rtmp'): - return self._download_with_rtmpdump(filename, url, player_url) - - tmpfilename = self.temp_name(filename) - stream = None - - # Do not include the Accept-Encoding header - headers = {'Youtubedl-no-compression': 'True'} - basic_request = urllib2.Request(url, None, headers) - request = urllib2.Request(url, None, headers) - - # Establish possible resume length - if os.path.isfile(_encodeFilename(tmpfilename)): - resume_len = os.path.getsize(_encodeFilename(tmpfilename)) - else: - resume_len = 0 - - open_mode = 'wb' - if resume_len != 0: - if self.params.get('continuedl', False): - self.report_resuming_byte(resume_len) - request.add_header('Range','bytes=%d-' % resume_len) - open_mode = 'ab' - else: - resume_len = 0 - - count = 0 - retries = self.params.get('retries', 0) - while count <= retries: - # Establish connection - try: - if count == 0 and 'urlhandle' in info_dict: - data = info_dict['urlhandle'] - data = urllib2.urlopen(request) - break - except (urllib2.HTTPError, ), err: - if (err.code < 500 or err.code >= 600) and err.code != 416: - # Unexpected HTTP error - raise - elif err.code == 416: - # Unable to resume (requested range not satisfiable) - try: - # Open the connection again without the range header - data = urllib2.urlopen(basic_request) - content_length = data.info()['Content-Length'] - except (urllib2.HTTPError, ), err: - if err.code < 500 or err.code >= 600: - raise - else: - # Examine the reported length - if (content_length is not None and - (resume_len - 100 < long(content_length) < resume_len + 100)): - # The file had already been fully downloaded. - # Explanation to the above condition: in issue #175 it was revealed that - # YouTube sometimes adds or removes a few bytes from the end of the file, - # changing the file size slightly and causing problems for some users. So - # I decided to implement a suggested change and consider the file - # completely downloaded if the file size differs less than 100 bytes from - # the one in the hard drive. - self.report_file_already_downloaded(filename) - self.try_rename(tmpfilename, filename) - return True - else: - # The length does not match, we start the download over - self.report_unable_to_resume() - open_mode = 'wb' - break - # Retry - count += 1 - if count <= retries: - self.report_retry(count, retries) - - if count > retries: - self.trouble(u'ERROR: giving up after %s retries' % retries) - return False - - data_len = data.info().get('Content-length', None) - if data_len is not None: - data_len = long(data_len) + resume_len - data_len_str = self.format_bytes(data_len) - byte_counter = 0 + resume_len - block_size = 1024 - start = time.time() - while True: - # Download and write - before = time.time() - data_block = data.read(block_size) - after = time.time() - if len(data_block) == 0: - break - byte_counter += len(data_block) - - # Open file just in time - if stream is None: - try: - (stream, tmpfilename) = sanitize_open(tmpfilename, open_mode) - assert stream is not None - filename = self.undo_temp_name(tmpfilename) - self.report_destination(filename) - except (OSError, IOError), err: - self.trouble(u'ERROR: unable to open for writing: %s' % str(err)) - return False - try: - stream.write(data_block) - except (IOError, OSError), err: - self.trouble(u'\nERROR: unable to write data: %s' % str(err)) - return False - block_size = self.best_block_size(after - before, len(data_block)) - - # Progress message - speed_str = self.calc_speed(start, time.time(), byte_counter - resume_len) - if data_len is None: - self.report_progress('Unknown %', data_len_str, speed_str, 'Unknown ETA') - else: - percent_str = self.calc_percent(byte_counter, data_len) - eta_str = self.calc_eta(start, time.time(), data_len - resume_len, byte_counter - resume_len) - self.report_progress(percent_str, data_len_str, speed_str, eta_str) - - # Apply rate limit - self.slow_down(start, byte_counter - resume_len) - - if stream is None: - self.trouble(u'\nERROR: Did not get any data blocks') - return False - stream.close() - self.report_finish() - if data_len is not None and byte_counter != data_len: - raise ContentTooShortError(byte_counter, long(data_len)) - self.try_rename(tmpfilename, filename) - - # Update file modification time - if self.params.get('updatetime', True): - info_dict['filetime'] = self.try_utime(filename, data.info().get('last-modified', None)) - - return True - - -class InfoExtractor(object): - """Information Extractor class. - - Information extractors are the classes that, given a URL, extract - information from the video (or videos) the URL refers to. This - information includes the real video URL, the video title and simplified - title, author and others. The information is stored in a dictionary - which is then passed to the FileDownloader. The FileDownloader - processes this information possibly downloading the video to the file - system, among other possible outcomes. The dictionaries must include - the following fields: - - id: Video identifier. - url: Final video URL. - uploader: Nickname of the video uploader. - title: Literal title. - stitle: Simplified title. - ext: Video filename extension. - format: Video format. - player_url: SWF Player URL (may be None). - - The following fields are optional. Their primary purpose is to allow - youtube-dl to serve as the backend for a video search function, such - as the one in youtube2mp3. They are only used when their respective - forced printing functions are called: - - thumbnail: Full URL to a video thumbnail image. - description: One-line video description. - - Subclasses of this one should re-define the _real_initialize() and - _real_extract() methods and define a _VALID_URL regexp. - Probably, they should also be added to the list of extractors. - """ - - _ready = False - _downloader = None - - def __init__(self, downloader=None): - """Constructor. Receives an optional downloader.""" - self._ready = False - self.set_downloader(downloader) - - def suitable(self, url): - """Receives a URL and returns True if suitable for this IE.""" - return re.match(self._VALID_URL, url) is not None - - def initialize(self): - """Initializes an instance (authentication, etc).""" - if not self._ready: - self._real_initialize() - self._ready = True - - def extract(self, url): - """Extracts URL information and returns it in list of dicts.""" - self.initialize() - return self._real_extract(url) - - def set_downloader(self, downloader): - """Sets the downloader for this IE.""" - self._downloader = downloader - - def _real_initialize(self): - """Real initialization process. Redefine in subclasses.""" - pass - - def _real_extract(self, url): - """Real extraction process. Redefine in subclasses.""" - pass - - -class YoutubeIE(InfoExtractor): - """Information extractor for youtube.com.""" - - _VALID_URL = r'^((?:https?://)?(?:youtu\.be/|(?:\w+\.)?youtube(?:-nocookie)?\.com/)(?!view_play_list|my_playlists|artist|playlist)(?:(?:(?:v|embed|e)/)|(?:(?:watch(?:_popup)?(?:\.php)?)?(?:\?|#!?)(?:.+&)?v=))?)?([0-9A-Za-z_-]+)(?(1).+)?$' - _LANG_URL = r'http://www.youtube.com/?hl=en&persist_hl=1&gl=US&persist_gl=1&opt_out_ackd=1' - _LOGIN_URL = 'https://www.youtube.com/signup?next=/&gl=US&hl=en' - _AGE_URL = 'http://www.youtube.com/verify_age?next_url=/&gl=US&hl=en' - _NEXT_URL_RE = r'[\?&]next_url=([^&]+)' - _NETRC_MACHINE = 'youtube' - # Listed in order of quality - _available_formats = ['38', '37', '22', '45', '35', '44', '34', '18', '43', '6', '5', '17', '13'] - _available_formats_prefer_free = ['38', '37', '45', '22', '44', '35', '43', '34', '18', '6', '5', '17', '13'] - _video_extensions = { - '13': '3gp', - '17': 'mp4', - '18': 'mp4', - '22': 'mp4', - '37': 'mp4', - '38': 'video', # You actually don't know if this will be MOV, AVI or whatever - '43': 'webm', - '44': 'webm', - '45': 'webm', - } - _video_dimensions = { - '5': '240x400', - '6': '???', - '13': '???', - '17': '144x176', - '18': '360x640', - '22': '720x1280', - '34': '360x640', - '35': '480x854', - '37': '1080x1920', - '38': '3072x4096', - '43': '360x640', - '44': '480x854', - '45': '720x1280', - } - IE_NAME = u'youtube' - - def report_lang(self): - """Report attempt to set language.""" - self._downloader.to_screen(u'[youtube] Setting language') - - def report_login(self): - """Report attempt to log in.""" - self._downloader.to_screen(u'[youtube] Logging in') - - def report_age_confirmation(self): - """Report attempt to confirm age.""" - self._downloader.to_screen(u'[youtube] Confirming age') - - def report_video_webpage_download(self, video_id): - """Report attempt to download video webpage.""" - self._downloader.to_screen(u'[youtube] %s: Downloading video webpage' % video_id) - - def report_video_info_webpage_download(self, video_id): - """Report attempt to download video info webpage.""" - self._downloader.to_screen(u'[youtube] %s: Downloading video info webpage' % video_id) - - def report_video_subtitles_download(self, video_id): - """Report attempt to download video info webpage.""" - self._downloader.to_screen(u'[youtube] %s: Downloading video subtitles' % video_id) - - def report_information_extraction(self, video_id): - """Report attempt to extract video information.""" - self._downloader.to_screen(u'[youtube] %s: Extracting video information' % video_id) - - def report_unavailable_format(self, video_id, format): - """Report extracted video URL.""" - self._downloader.to_screen(u'[youtube] %s: Format %s not available' % (video_id, format)) - - def report_rtmp_download(self): - """Indicate the download will use the RTMP protocol.""" - self._downloader.to_screen(u'[youtube] RTMP download detected') - - def _closed_captions_xml_to_srt(self, xml_string): - srt = '' - texts = re.findall(r'<text start="([\d\.]+)"( dur="([\d\.]+)")?>([^<]+)</text>', xml_string, re.MULTILINE) - # TODO parse xml instead of regex - for n, (start, dur_tag, dur, caption) in enumerate(texts): - if not dur: dur = '4' - start = float(start) - end = start + float(dur) - start = "%02i:%02i:%02i,%03i" %(start/(60*60), start/60%60, start%60, start%1*1000) - end = "%02i:%02i:%02i,%03i" %(end/(60*60), end/60%60, end%60, end%1*1000) - caption = re.sub(ur'(?u)&(.+?);', htmlentity_transform, caption) - caption = re.sub(ur'(?u)&(.+?);', htmlentity_transform, caption) # double cycle, inentional - srt += str(n) + '\n' - srt += start + ' --> ' + end + '\n' - srt += caption + '\n\n' - return srt - - def _print_formats(self, formats): - print 'Available formats:' - for x in formats: - print '%s\t:\t%s\t[%s]' %(x, self._video_extensions.get(x, 'flv'), self._video_dimensions.get(x, '???')) - - def _real_initialize(self): - if self._downloader is None: - return - - username = None - password = None - downloader_params = self._downloader.params - - # Attempt to use provided username and password or .netrc data - if downloader_params.get('username', None) is not None: - username = downloader_params['username'] - password = downloader_params['password'] - elif downloader_params.get('usenetrc', False): - try: - info = netrc.netrc().authenticators(self._NETRC_MACHINE) - if info is not None: - username = info[0] - password = info[2] - else: - raise netrc.NetrcParseError('No authenticators for %s' % self._NETRC_MACHINE) - except (IOError, netrc.NetrcParseError), err: - self._downloader.to_stderr(u'WARNING: parsing .netrc: %s' % str(err)) - return - - # Set language - request = urllib2.Request(self._LANG_URL) - try: - self.report_lang() - urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.to_stderr(u'WARNING: unable to set language: %s' % str(err)) - return - - # No authentication to be performed - if username is None: - return - - # Log in - login_form = { - 'current_form': 'loginForm', - 'next': '/', - 'action_login': 'Log In', - 'username': username, - 'password': password, - } - request = urllib2.Request(self._LOGIN_URL, urllib.urlencode(login_form)) - try: - self.report_login() - login_results = urllib2.urlopen(request).read() - if re.search(r'(?i)<form[^>]* name="loginForm"', login_results) is not None: - self._downloader.to_stderr(u'WARNING: unable to log in: bad username or password') - return - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.to_stderr(u'WARNING: unable to log in: %s' % str(err)) - return - - # Confirm age - age_form = { - 'next_url': '/', - 'action_confirm': 'Confirm', - } - request = urllib2.Request(self._AGE_URL, urllib.urlencode(age_form)) - try: - self.report_age_confirmation() - age_results = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to confirm age: %s' % str(err)) - return - - def _real_extract(self, url): - # Extract original video URL from URL with redirection, like age verification, using next_url parameter - mobj = re.search(self._NEXT_URL_RE, url) - if mobj: - url = 'http://www.youtube.com/' + urllib.unquote(mobj.group(1)).lstrip('/') - - # Extract video id from URL - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - video_id = mobj.group(2) - - # Get video webpage - self.report_video_webpage_download(video_id) - request = urllib2.Request('http://www.youtube.com/watch?v=%s&gl=US&hl=en&has_verified=1' % video_id) - try: - video_webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) - return - - # Attempt to extract SWF player URL - mobj = re.search(r'swfConfig.*?"(http:\\/\\/.*?watch.*?-.*?\.swf)"', video_webpage) - if mobj is not None: - player_url = re.sub(r'\\(.)', r'\1', mobj.group(1)) - else: - player_url = None - - # Get video info - self.report_video_info_webpage_download(video_id) - for el_type in ['&el=embedded', '&el=detailpage', '&el=vevo', '']: - video_info_url = ('http://www.youtube.com/get_video_info?&video_id=%s%s&ps=default&eurl=&gl=US&hl=en' - % (video_id, el_type)) - request = urllib2.Request(video_info_url) - try: - video_info_webpage = urllib2.urlopen(request).read() - video_info = parse_qs(video_info_webpage) - if 'token' in video_info: - break - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video info webpage: %s' % str(err)) - return - if 'token' not in video_info: - if 'reason' in video_info: - self._downloader.trouble(u'ERROR: YouTube said: %s' % video_info['reason'][0].decode('utf-8')) - else: - self._downloader.trouble(u'ERROR: "token" parameter not in video info for unknown reason') - return - - # Start extracting information - self.report_information_extraction(video_id) - - # uploader - if 'author' not in video_info: - self._downloader.trouble(u'ERROR: unable to extract uploader nickname') - return - video_uploader = urllib.unquote_plus(video_info['author'][0]) - - # title - if 'title' not in video_info: - self._downloader.trouble(u'ERROR: unable to extract video title') - return - video_title = urllib.unquote_plus(video_info['title'][0]) - video_title = video_title.decode('utf-8') - video_title = sanitize_title(video_title) - - # simplified title - simple_title = _simplify_title(video_title) - - # thumbnail image - if 'thumbnail_url' not in video_info: - self._downloader.trouble(u'WARNING: unable to extract video thumbnail') - video_thumbnail = '' - else: # don't panic if we can't find it - video_thumbnail = urllib.unquote_plus(video_info['thumbnail_url'][0]) - - # upload date - upload_date = u'NA' - mobj = re.search(r'id="eow-date.*?>(.*?)</span>', video_webpage, re.DOTALL) - if mobj is not None: - upload_date = ' '.join(re.sub(r'[/,-]', r' ', mobj.group(1)).split()) - format_expressions = ['%d %B %Y', '%B %d %Y', '%b %d %Y'] - for expression in format_expressions: - try: - upload_date = datetime.datetime.strptime(upload_date, expression).strftime('%Y%m%d') - except: - pass - - # description - try: - lxml.etree - except NameError: - video_description = u'No description available.' - mobj = re.search(r'<meta name="description" content="(.*?)">', video_webpage) - if mobj is not None: - video_description = mobj.group(1).decode('utf-8') - else: - html_parser = lxml.etree.HTMLParser(encoding='utf-8') - vwebpage_doc = lxml.etree.parse(StringIO.StringIO(video_webpage), html_parser) - video_description = u''.join(vwebpage_doc.xpath('id("eow-description")//text()')) - # TODO use another parser - - # closed captions - video_subtitles = None - if self._downloader.params.get('writesubtitles', False): - self.report_video_subtitles_download(video_id) - request = urllib2.Request('http://video.google.com/timedtext?hl=en&type=list&v=%s' % video_id) - try: - srt_list = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'WARNING: unable to download video subtitles: %s' % str(err)) - else: - srt_lang_list = re.findall(r'lang_code="([\w\-]+)"', srt_list) - if srt_lang_list: - if self._downloader.params.get('subtitleslang', False): - srt_lang = self._downloader.params.get('subtitleslang') - elif 'en' in srt_lang_list: - srt_lang = 'en' - else: - srt_lang = srt_lang_list[0] - if not srt_lang in srt_lang_list: - self._downloader.trouble(u'WARNING: no closed captions found in the specified language') - else: - request = urllib2.Request('http://video.google.com/timedtext?hl=en&lang=%s&v=%s' % (srt_lang, video_id)) - try: - srt_xml = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'WARNING: unable to download video subtitles: %s' % str(err)) - else: - video_subtitles = self._closed_captions_xml_to_srt(srt_xml.decode('utf-8')) - else: - self._downloader.trouble(u'WARNING: video has no closed captions') - - # token - video_token = urllib.unquote_plus(video_info['token'][0]) - - # Decide which formats to download - req_format = self._downloader.params.get('format', None) - - if 'conn' in video_info and video_info['conn'][0].startswith('rtmp'): - self.report_rtmp_download() - video_url_list = [(None, video_info['conn'][0])] - elif 'url_encoded_fmt_stream_map' in video_info and len(video_info['url_encoded_fmt_stream_map']) >= 1: - url_data_strs = video_info['url_encoded_fmt_stream_map'][0].split(',') - url_data = [parse_qs(uds) for uds in url_data_strs] - url_data = filter(lambda ud: 'itag' in ud and 'url' in ud, url_data) - url_map = dict((ud['itag'][0], ud['url'][0]) for ud in url_data) - - format_limit = self._downloader.params.get('format_limit', None) - available_formats = self._available_formats_prefer_free if self._downloader.params.get('prefer_free_formats', False) else self._available_formats - if format_limit is not None and format_limit in available_formats: - format_list = available_formats[available_formats.index(format_limit):] - else: - format_list = available_formats - existing_formats = [x for x in format_list if x in url_map] - if len(existing_formats) == 0: - self._downloader.trouble(u'ERROR: no known formats available for video') - return - if self._downloader.params.get('listformats', None): - self._print_formats(existing_formats) - return - if req_format is None or req_format == 'best': - video_url_list = [(existing_formats[0], url_map[existing_formats[0]])] # Best quality - elif req_format == 'worst': - video_url_list = [(existing_formats[len(existing_formats)-1], url_map[existing_formats[len(existing_formats)-1]])] # worst quality - elif req_format in ('-1', 'all'): - video_url_list = [(f, url_map[f]) for f in existing_formats] # All formats - else: - # Specific formats. We pick the first in a slash-delimeted sequence. - # For example, if '1/2/3/4' is requested and '2' and '4' are available, we pick '2'. - req_formats = req_format.split('/') - video_url_list = None - for rf in req_formats: - if rf in url_map: - video_url_list = [(rf, url_map[rf])] - break - if video_url_list is None: - self._downloader.trouble(u'ERROR: requested format not available') - return - else: - self._downloader.trouble(u'ERROR: no conn or url_encoded_fmt_stream_map information found in video info') - return - - for format_param, video_real_url in video_url_list: - # At this point we have a new video - self._downloader.increment_downloads() - - # Extension - video_extension = self._video_extensions.get(format_param, 'flv') - - try: - # Process video information - self._downloader.process_info({ - 'id': video_id.decode('utf-8'), - 'url': video_real_url.decode('utf-8'), - 'uploader': video_uploader.decode('utf-8'), - 'upload_date': upload_date, - 'title': video_title, - 'stitle': simple_title, - 'ext': video_extension.decode('utf-8'), - 'format': (format_param is None and u'NA' or format_param.decode('utf-8')), - 'thumbnail': video_thumbnail.decode('utf-8'), - 'description': video_description, - 'player_url': player_url, - 'subtitles': video_subtitles - }) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class MetacafeIE(InfoExtractor): - """Information Extractor for metacafe.com.""" - - _VALID_URL = r'(?:http://)?(?:www\.)?metacafe\.com/watch/([^/]+)/([^/]+)/.*' - _DISCLAIMER = 'http://www.metacafe.com/family_filter/' - _FILTER_POST = 'http://www.metacafe.com/f/index.php?inputType=filter&controllerGroup=user' - _youtube_ie = None - IE_NAME = u'metacafe' - - def __init__(self, youtube_ie, downloader=None): - InfoExtractor.__init__(self, downloader) - self._youtube_ie = youtube_ie - - def report_disclaimer(self): - """Report disclaimer retrieval.""" - self._downloader.to_screen(u'[metacafe] Retrieving disclaimer') - - def report_age_confirmation(self): - """Report attempt to confirm age.""" - self._downloader.to_screen(u'[metacafe] Confirming age') - - def report_download_webpage(self, video_id): - """Report webpage download.""" - self._downloader.to_screen(u'[metacafe] %s: Downloading webpage' % video_id) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[metacafe] %s: Extracting information' % video_id) - - def _real_initialize(self): - # Retrieve disclaimer - request = urllib2.Request(self._DISCLAIMER) - try: - self.report_disclaimer() - disclaimer = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to retrieve disclaimer: %s' % str(err)) - return - - # Confirm age - disclaimer_form = { - 'filters': '0', - 'submit': "Continue - I'm over 18", - } - request = urllib2.Request(self._FILTER_POST, urllib.urlencode(disclaimer_form)) - try: - self.report_age_confirmation() - disclaimer = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to confirm age: %s' % str(err)) - return - - def _real_extract(self, url): - # Extract id and simplified title from URL - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - - video_id = mobj.group(1) - - # Check if video comes from YouTube - mobj2 = re.match(r'^yt-(.*)$', video_id) - if mobj2 is not None: - self._youtube_ie.extract('http://www.youtube.com/watch?v=%s' % mobj2.group(1)) - return - - # At this point we have a new video - self._downloader.increment_downloads() - - simple_title = mobj.group(2).decode('utf-8') - - # Retrieve video webpage to extract further information - request = urllib2.Request('http://www.metacafe.com/watch/%s/' % video_id) - try: - self.report_download_webpage(video_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable retrieve video webpage: %s' % str(err)) - return - - # Extract URL, uploader and title from webpage - self.report_extraction(video_id) - mobj = re.search(r'(?m)&mediaURL=([^&]+)', webpage) - if mobj is not None: - mediaURL = urllib.unquote(mobj.group(1)) - video_extension = mediaURL[-3:] - - # Extract gdaKey if available - mobj = re.search(r'(?m)&gdaKey=(.*?)&', webpage) - if mobj is None: - video_url = mediaURL - else: - gdaKey = mobj.group(1) - video_url = '%s?__gda__=%s' % (mediaURL, gdaKey) - else: - mobj = re.search(r' name="flashvars" value="(.*?)"', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract media URL') - return - vardict = parse_qs(mobj.group(1)) - if 'mediaData' not in vardict: - self._downloader.trouble(u'ERROR: unable to extract media URL') - return - mobj = re.search(r'"mediaURL":"(http.*?)","key":"(.*?)"', vardict['mediaData'][0]) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract media URL') - return - mediaURL = mobj.group(1).replace('\\/', '/') - video_extension = mediaURL[-3:] - video_url = '%s?__gda__=%s' % (mediaURL, mobj.group(2)) - - mobj = re.search(r'(?im)<title>(.*) - Video', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract title') - return - video_title = mobj.group(1).decode('utf-8') - video_title = sanitize_title(video_title) - - mobj = re.search(r'(?ms)By:\s*(.+?)<', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract uploader nickname') - return - video_uploader = mobj.group(1) - - try: - # Process video information - self._downloader.process_info({ - 'id': video_id.decode('utf-8'), - 'url': video_url.decode('utf-8'), - 'uploader': video_uploader.decode('utf-8'), - 'upload_date': u'NA', - 'title': video_title, - 'stitle': simple_title, - 'ext': video_extension.decode('utf-8'), - 'format': u'NA', - 'player_url': None, - }) - except UnavailableVideoError: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class DailymotionIE(InfoExtractor): - """Information Extractor for Dailymotion""" - - _VALID_URL = r'(?i)(?:https?://)?(?:www\.)?dailymotion\.[a-z]{2,3}/video/([^_/]+)_([^/]+)' - IE_NAME = u'dailymotion' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_download_webpage(self, video_id): - """Report webpage download.""" - self._downloader.to_screen(u'[dailymotion] %s: Downloading webpage' % video_id) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[dailymotion] %s: Extracting information' % video_id) - - def _real_extract(self, url): - # Extract id and simplified title from URL - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - - # At this point we have a new video - self._downloader.increment_downloads() - video_id = mobj.group(1) - - video_extension = 'flv' - - # Retrieve video webpage to extract further information - request = urllib2.Request(url) - request.add_header('Cookie', 'family_filter=off') - try: - self.report_download_webpage(video_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable retrieve video webpage: %s' % str(err)) - return - - # Extract URL, uploader and title from webpage - self.report_extraction(video_id) - mobj = re.search(r'(?i)addVariable\(\"sequence\"\s*,\s*\"([^\"]+?)\"\)', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract media URL') - return - sequence = urllib.unquote(mobj.group(1)) - mobj = re.search(r',\"sdURL\"\:\"([^\"]+?)\",', sequence) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract media URL') - return - mediaURL = urllib.unquote(mobj.group(1)).replace('\\', '') - - # if needed add http://www.dailymotion.com/ if relative URL - - video_url = mediaURL - - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract title') - return - video_title = _unescapeHTML(mobj.group('title').decode('utf-8')) - video_title = sanitize_title(video_title) - simple_title = _simplify_title(video_title) - - mobj = re.search(r'(?im)[^<]+?]+?>([^<]+?)', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract uploader nickname') - return - video_uploader = mobj.group(1) - - try: - # Process video information - self._downloader.process_info({ - 'id': video_id.decode('utf-8'), - 'url': video_url.decode('utf-8'), - 'uploader': video_uploader.decode('utf-8'), - 'upload_date': u'NA', - 'title': video_title, - 'stitle': simple_title, - 'ext': video_extension.decode('utf-8'), - 'format': u'NA', - 'player_url': None, - }) - except UnavailableVideoError: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class GoogleIE(InfoExtractor): - """Information extractor for video.google.com.""" - - _VALID_URL = r'(?:http://)?video\.google\.(?:com(?:\.au)?|co\.(?:uk|jp|kr|cr)|ca|de|es|fr|it|nl|pl)/videoplay\?docid=([^\&]+).*' - IE_NAME = u'video.google' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_download_webpage(self, video_id): - """Report webpage download.""" - self._downloader.to_screen(u'[video.google] %s: Downloading webpage' % video_id) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[video.google] %s: Extracting information' % video_id) - - def _real_extract(self, url): - # Extract id from URL - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) - return - - # At this point we have a new video - self._downloader.increment_downloads() - video_id = mobj.group(1) - - video_extension = 'mp4' - - # Retrieve video webpage to extract further information - request = urllib2.Request('http://video.google.com/videoplay?docid=%s&hl=en&oe=utf-8' % video_id) - try: - self.report_download_webpage(video_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) - return - - # Extract URL, uploader, and title from webpage - self.report_extraction(video_id) - mobj = re.search(r"download_url:'([^']+)'", webpage) - if mobj is None: - video_extension = 'flv' - mobj = re.search(r"(?i)videoUrl\\x3d(.+?)\\x26", webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract media URL') - return - mediaURL = urllib.unquote(mobj.group(1)) - mediaURL = mediaURL.replace('\\x3d', '\x3d') - mediaURL = mediaURL.replace('\\x26', '\x26') - - video_url = mediaURL - - mobj = re.search(r'(.*)', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract title') - return - video_title = mobj.group(1).decode('utf-8') - video_title = sanitize_title(video_title) - simple_title = _simplify_title(video_title) - - # Extract video description - mobj = re.search(r'([^<]*)', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video description') - return - video_description = mobj.group(1).decode('utf-8') - if not video_description: - video_description = 'No description available.' - - # Extract video thumbnail - if self._downloader.params.get('forcethumbnail', False): - request = urllib2.Request('http://video.google.com/videosearch?q=%s+site:video.google.com&hl=en' % abs(int(video_id))) - try: - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) - return - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video thumbnail') - return - video_thumbnail = mobj.group(1) - else: # we need something to pass to process_info - video_thumbnail = '' - - try: - # Process video information - self._downloader.process_info({ - 'id': video_id.decode('utf-8'), - 'url': video_url.decode('utf-8'), - 'uploader': u'NA', - 'upload_date': u'NA', - 'title': video_title, - 'stitle': simple_title, - 'ext': video_extension.decode('utf-8'), - 'format': u'NA', - 'player_url': None, - }) - except UnavailableVideoError: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class PhotobucketIE(InfoExtractor): - """Information extractor for photobucket.com.""" - - _VALID_URL = r'(?:http://)?(?:[a-z0-9]+\.)?photobucket\.com/.*[\?\&]current=(.*\.flv)' - IE_NAME = u'photobucket' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_download_webpage(self, video_id): - """Report webpage download.""" - self._downloader.to_screen(u'[photobucket] %s: Downloading webpage' % video_id) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[photobucket] %s: Extracting information' % video_id) - - def _real_extract(self, url): - # Extract id from URL - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) - return - - # At this point we have a new video - self._downloader.increment_downloads() - video_id = mobj.group(1) - - video_extension = 'flv' - - # Retrieve video webpage to extract further information - request = urllib2.Request(url) - try: - self.report_download_webpage(video_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) - return - - # Extract URL, uploader, and title from webpage - self.report_extraction(video_id) - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract media URL') - return - mediaURL = urllib.unquote(mobj.group(1)) - - video_url = mediaURL - - mobj = re.search(r'(.*) video by (.*) - Photobucket', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract title') - return - video_title = mobj.group(1).decode('utf-8') - video_title = sanitize_title(video_title) - simple_title = _simplify_title(vide_title) - - video_uploader = mobj.group(2).decode('utf-8') - - try: - # Process video information - self._downloader.process_info({ - 'id': video_id.decode('utf-8'), - 'url': video_url.decode('utf-8'), - 'uploader': video_uploader, - 'upload_date': u'NA', - 'title': video_title, - 'stitle': simple_title, - 'ext': video_extension.decode('utf-8'), - 'format': u'NA', - 'player_url': None, - }) - except UnavailableVideoError: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class YahooIE(InfoExtractor): - """Information extractor for video.yahoo.com.""" - - # _VALID_URL matches all Yahoo! Video URLs - # _VPAGE_URL matches only the extractable '/watch/' URLs - _VALID_URL = r'(?:http://)?(?:[a-z]+\.)?video\.yahoo\.com/(?:watch|network)/([0-9]+)(?:/|\?v=)([0-9]+)(?:[#\?].*)?' - _VPAGE_URL = r'(?:http://)?video\.yahoo\.com/watch/([0-9]+)/([0-9]+)(?:[#\?].*)?' - IE_NAME = u'video.yahoo' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_download_webpage(self, video_id): - """Report webpage download.""" - self._downloader.to_screen(u'[video.yahoo] %s: Downloading webpage' % video_id) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[video.yahoo] %s: Extracting information' % video_id) - - def _real_extract(self, url, new_video=True): - # Extract ID from URL - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) - return - - # At this point we have a new video - self._downloader.increment_downloads() - video_id = mobj.group(2) - video_extension = 'flv' - - # Rewrite valid but non-extractable URLs as - # extractable English language /watch/ URLs - if re.match(self._VPAGE_URL, url) is None: - request = urllib2.Request(url) - try: - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) - return - - mobj = re.search(r'\("id", "([0-9]+)"\);', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: Unable to extract id field') - return - yahoo_id = mobj.group(1) - - mobj = re.search(r'\("vid", "([0-9]+)"\);', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: Unable to extract vid field') - return - yahoo_vid = mobj.group(1) - - url = 'http://video.yahoo.com/watch/%s/%s' % (yahoo_vid, yahoo_id) - return self._real_extract(url, new_video=False) - - # Retrieve video webpage to extract further information - request = urllib2.Request(url) - try: - self.report_download_webpage(video_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) - return - - # Extract uploader and title from webpage - self.report_extraction(video_id) - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video title') - return - video_title = mobj.group(1).decode('utf-8') - simple_title = _simplify_title(video_title) - - mobj = re.search(r'

(.*)

', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video uploader') - return - video_uploader = mobj.group(1).decode('utf-8') - - # Extract video thumbnail - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video thumbnail') - return - video_thumbnail = mobj.group(1).decode('utf-8') - - # Extract video description - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video description') - return - video_description = mobj.group(1).decode('utf-8') - if not video_description: - video_description = 'No description available.' - - # Extract video height and width - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video height') - return - yv_video_height = mobj.group(1) - - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video width') - return - yv_video_width = mobj.group(1) - - # Retrieve video playlist to extract media URL - # I'm not completely sure what all these options are, but we - # seem to need most of them, otherwise the server sends a 401. - yv_lg = 'R0xx6idZnW2zlrKP8xxAIR' # not sure what this represents - yv_bitrate = '700' # according to Wikipedia this is hard-coded - request = urllib2.Request('http://cosmos.bcst.yahoo.com/up/yep/process/getPlaylistFOP.php?node_id=' + video_id + - '&tech=flash&mode=playlist&lg=' + yv_lg + '&bitrate=' + yv_bitrate + '&vidH=' + yv_video_height + - '&vidW=' + yv_video_width + '&swf=as3&rd=video.yahoo.com&tk=null&adsupported=v1,v2,&eventid=1301797') - try: - self.report_download_webpage(video_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) - return - - # Extract media URL from playlist XML - mobj = re.search(r'', webpage, re.MULTILINE) - if mobj is not None: - video_description = mobj.group(1) - else: - html_parser = lxml.etree.HTMLParser() - vwebpage_doc = lxml.etree.parse(StringIO.StringIO(webpage), html_parser) - video_description = u''.join(vwebpage_doc.xpath('id("description")//text()')).strip() - # TODO use another parser - - # Extract upload date - video_upload_date = u'NA' - mobj = re.search(r'', webpage) - if mobj is not None: - video_upload_date = mobj.group(1) - - # Vimeo specific: extract request signature and timestamp - sig = config['request']['signature'] - timestamp = config['request']['timestamp'] - - # Vimeo specific: extract video codec and quality information - # TODO bind to format param - codecs = [('h264', 'mp4'), ('vp8', 'flv'), ('vp6', 'flv')] - for codec in codecs: - if codec[0] in config["video"]["files"]: - video_codec = codec[0] - video_extension = codec[1] - if 'hd' in config["video"]["files"][codec[0]]: quality = 'hd' - else: quality = 'sd' - break - else: - self._downloader.trouble(u'ERROR: no known codec found') - return - - video_url = "http://player.vimeo.com/play_redirect?clip_id=%s&sig=%s&time=%s&quality=%s&codecs=%s&type=moogaloop_local&embed_location=" \ - %(video_id, sig, timestamp, quality, video_codec.upper()) - - try: - # Process video information - self._downloader.process_info({ - 'id': video_id, - 'url': video_url, - 'uploader': video_uploader, - 'upload_date': video_upload_date, - 'title': video_title, - 'stitle': simple_title, - 'ext': video_extension, - 'thumbnail': video_thumbnail, - 'description': video_description, - 'player_url': None, - }) - except UnavailableVideoError: - self._downloader.trouble(u'ERROR: unable to download video') - - -class GenericIE(InfoExtractor): - """Generic last-resort information extractor.""" - - _VALID_URL = r'.*' - IE_NAME = u'generic' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_download_webpage(self, video_id): - """Report webpage download.""" - self._downloader.to_screen(u'WARNING: Falling back on generic information extractor.') - self._downloader.to_screen(u'[generic] %s: Downloading webpage' % video_id) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[generic] %s: Extracting information' % video_id) - - def report_following_redirect(self, new_url): - """Report information extraction.""" - self._downloader.to_screen(u'[redirect] Following redirect to %s' % new_url) - - def _test_redirect(self, url): - """Check if it is a redirect, like url shorteners, in case restart chain.""" - class HeadRequest(urllib2.Request): - def get_method(self): - return "HEAD" - - class HEADRedirectHandler(urllib2.HTTPRedirectHandler): - """ - Subclass the HTTPRedirectHandler to make it use our - HeadRequest also on the redirected URL - """ - def redirect_request(self, req, fp, code, msg, headers, newurl): - if code in (301, 302, 303, 307): - newurl = newurl.replace(' ', '%20') - newheaders = dict((k,v) for k,v in req.headers.items() - if k.lower() not in ("content-length", "content-type")) - return HeadRequest(newurl, - headers=newheaders, - origin_req_host=req.get_origin_req_host(), - unverifiable=True) - else: - raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) - - class HTTPMethodFallback(urllib2.BaseHandler): - """ - Fallback to GET if HEAD is not allowed (405 HTTP error) - """ - def http_error_405(self, req, fp, code, msg, headers): - fp.read() - fp.close() - - newheaders = dict((k,v) for k,v in req.headers.items() - if k.lower() not in ("content-length", "content-type")) - return self.parent.open(urllib2.Request(req.get_full_url(), - headers=newheaders, - origin_req_host=req.get_origin_req_host(), - unverifiable=True)) - - # Build our opener - opener = urllib2.OpenerDirector() - for handler in [urllib2.HTTPHandler, urllib2.HTTPDefaultErrorHandler, - HTTPMethodFallback, HEADRedirectHandler, - urllib2.HTTPErrorProcessor, urllib2.HTTPSHandler]: - opener.add_handler(handler()) - - response = opener.open(HeadRequest(url)) - new_url = response.geturl() - - if url == new_url: return False - - self.report_following_redirect(new_url) - self._downloader.download([new_url]) - return True - - def _real_extract(self, url): - if self._test_redirect(url): return - - # At this point we have a new video - self._downloader.increment_downloads() - - video_id = url.split('/')[-1] - request = urllib2.Request(url) - try: - self.report_download_webpage(video_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) - return - except ValueError, err: - # since this is the last-resort InfoExtractor, if - # this error is thrown, it'll be thrown here - self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) - return - - self.report_extraction(video_id) - # Start with something easy: JW Player in SWFObject - mobj = re.search(r'flashvars: [\'"](?:.*&)?file=(http[^\'"&]*)', webpage) - if mobj is None: - # Broaden the search a little bit - mobj = re.search(r'[^A-Za-z0-9]?(?:file|source)=(http[^\'"&]*)', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) - return - - # It's possible that one of the regexes - # matched, but returned an empty group: - if mobj.group(1) is None: - self._downloader.trouble(u'ERROR: Invalid URL: %s' % url) - return - - video_url = urllib.unquote(mobj.group(1)) - video_id = os.path.basename(video_url) - - # here's a fun little line of code for you: - video_extension = os.path.splitext(video_id)[1][1:] - video_id = os.path.splitext(video_id)[0] - - # it's tempting to parse this further, but you would - # have to take into account all the variations like - # Video Title - Site Name - # Site Name | Video Title - # Video Title - Tagline | Site Name - # and so on and so forth; it's just not practical - mobj = re.search(r'(.*)', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract title') - return - video_title = mobj.group(1).decode('utf-8') - video_title = sanitize_title(video_title) - simple_title = _simplify_title(video_title) - - # video uploader is domain name - mobj = re.match(r'(?:https?://)?([^/]*)/.*', url) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract title') - return - video_uploader = mobj.group(1).decode('utf-8') - - try: - # Process video information - self._downloader.process_info({ - 'id': video_id.decode('utf-8'), - 'url': video_url.decode('utf-8'), - 'uploader': video_uploader, - 'upload_date': u'NA', - 'title': video_title, - 'stitle': simple_title, - 'ext': video_extension.decode('utf-8'), - 'format': u'NA', - 'player_url': None, - }) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class YoutubeSearchIE(InfoExtractor): - """Information Extractor for YouTube search queries.""" - _VALID_URL = r'ytsearch(\d+|all)?:[\s\S]+' - _API_URL = 'https://gdata.youtube.com/feeds/api/videos?q=%s&start-index=%i&max-results=50&v=2&alt=jsonc' - _youtube_ie = None - _max_youtube_results = 1000 - IE_NAME = u'youtube:search' - - def __init__(self, youtube_ie, downloader=None): - InfoExtractor.__init__(self, downloader) - self._youtube_ie = youtube_ie - - def report_download_page(self, query, pagenum): - """Report attempt to download playlist page with given number.""" - query = query.decode(preferredencoding()) - self._downloader.to_screen(u'[youtube] query "%s": Downloading page %s' % (query, pagenum)) - - def _real_initialize(self): - self._youtube_ie.initialize() - - def _real_extract(self, query): - mobj = re.match(self._VALID_URL, query) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid search query "%s"' % query) - return - - prefix, query = query.split(':') - prefix = prefix[8:] - query = query.encode('utf-8') - if prefix == '': - self._download_n_results(query, 1) - return - elif prefix == 'all': - self._download_n_results(query, self._max_youtube_results) - return - else: - try: - n = long(prefix) - if n <= 0: - self._downloader.trouble(u'ERROR: invalid download number %s for query "%s"' % (n, query)) - return - elif n > self._max_youtube_results: - self._downloader.to_stderr(u'WARNING: ytsearch returns max %i results (you requested %i)' % (self._max_youtube_results, n)) - n = self._max_youtube_results - self._download_n_results(query, n) - return - except ValueError: # parsing prefix as integer fails - self._download_n_results(query, 1) - return - - def _download_n_results(self, query, n): - """Downloads a specified number of results for a query""" - - video_ids = [] - pagenum = 0 - limit = n - - while (50 * pagenum) < limit: - self.report_download_page(query, pagenum+1) - result_url = self._API_URL % (urllib.quote_plus(query), (50*pagenum)+1) - request = urllib2.Request(result_url) - try: - data = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download API page: %s' % str(err)) - return - api_response = json.loads(data)['data'] - - new_ids = list(video['id'] for video in api_response['items']) - video_ids += new_ids - - limit = min(n, api_response['totalItems']) - pagenum += 1 - - if len(video_ids) > n: - video_ids = video_ids[:n] - for id in video_ids: - self._youtube_ie.extract('http://www.youtube.com/watch?v=%s' % id) - return - - -class GoogleSearchIE(InfoExtractor): - """Information Extractor for Google Video search queries.""" - _VALID_URL = r'gvsearch(\d+|all)?:[\s\S]+' - _TEMPLATE_URL = 'http://video.google.com/videosearch?q=%s+site:video.google.com&start=%s&hl=en' - _VIDEO_INDICATOR = r' self._max_google_results: - self._downloader.to_stderr(u'WARNING: gvsearch returns max %i results (you requested %i)' % (self._max_google_results, n)) - n = self._max_google_results - self._download_n_results(query, n) - return - except ValueError: # parsing prefix as integer fails - self._download_n_results(query, 1) - return - - def _download_n_results(self, query, n): - """Downloads a specified number of results for a query""" - - video_ids = [] - pagenum = 0 - - while True: - self.report_download_page(query, pagenum) - result_url = self._TEMPLATE_URL % (urllib.quote_plus(query), pagenum*10) - request = urllib2.Request(result_url) - try: - page = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download webpage: %s' % str(err)) - return - - # Extract video identifiers - for mobj in re.finditer(self._VIDEO_INDICATOR, page): - video_id = mobj.group(1) - if video_id not in video_ids: - video_ids.append(video_id) - if len(video_ids) == n: - # Specified n videos reached - for id in video_ids: - self._google_ie.extract('http://video.google.com/videoplay?docid=%s' % id) - return - - if re.search(self._MORE_PAGES_INDICATOR, page) is None: - for id in video_ids: - self._google_ie.extract('http://video.google.com/videoplay?docid=%s' % id) - return - - pagenum = pagenum + 1 - - -class YahooSearchIE(InfoExtractor): - """Information Extractor for Yahoo! Video search queries.""" - _VALID_URL = r'yvsearch(\d+|all)?:[\s\S]+' - _TEMPLATE_URL = 'http://video.yahoo.com/search/?p=%s&o=%s' - _VIDEO_INDICATOR = r'href="http://video\.yahoo\.com/watch/([0-9]+/[0-9]+)"' - _MORE_PAGES_INDICATOR = r'\s*Next' - _yahoo_ie = None - _max_yahoo_results = 1000 - IE_NAME = u'video.yahoo:search' - - def __init__(self, yahoo_ie, downloader=None): - InfoExtractor.__init__(self, downloader) - self._yahoo_ie = yahoo_ie - - def report_download_page(self, query, pagenum): - """Report attempt to download playlist page with given number.""" - query = query.decode(preferredencoding()) - self._downloader.to_screen(u'[video.yahoo] query "%s": Downloading page %s' % (query, pagenum)) - - def _real_initialize(self): - self._yahoo_ie.initialize() - - def _real_extract(self, query): - mobj = re.match(self._VALID_URL, query) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid search query "%s"' % query) - return - - prefix, query = query.split(':') - prefix = prefix[8:] - query = query.encode('utf-8') - if prefix == '': - self._download_n_results(query, 1) - return - elif prefix == 'all': - self._download_n_results(query, self._max_yahoo_results) - return - else: - try: - n = long(prefix) - if n <= 0: - self._downloader.trouble(u'ERROR: invalid download number %s for query "%s"' % (n, query)) - return - elif n > self._max_yahoo_results: - self._downloader.to_stderr(u'WARNING: yvsearch returns max %i results (you requested %i)' % (self._max_yahoo_results, n)) - n = self._max_yahoo_results - self._download_n_results(query, n) - return - except ValueError: # parsing prefix as integer fails - self._download_n_results(query, 1) - return - - def _download_n_results(self, query, n): - """Downloads a specified number of results for a query""" - - video_ids = [] - already_seen = set() - pagenum = 1 - - while True: - self.report_download_page(query, pagenum) - result_url = self._TEMPLATE_URL % (urllib.quote_plus(query), pagenum) - request = urllib2.Request(result_url) - try: - page = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download webpage: %s' % str(err)) - return - - # Extract video identifiers - for mobj in re.finditer(self._VIDEO_INDICATOR, page): - video_id = mobj.group(1) - if video_id not in already_seen: - video_ids.append(video_id) - already_seen.add(video_id) - if len(video_ids) == n: - # Specified n videos reached - for id in video_ids: - self._yahoo_ie.extract('http://video.yahoo.com/watch/%s' % id) - return - - if re.search(self._MORE_PAGES_INDICATOR, page) is None: - for id in video_ids: - self._yahoo_ie.extract('http://video.yahoo.com/watch/%s' % id) - return - - pagenum = pagenum + 1 - - -class YoutubePlaylistIE(InfoExtractor): - """Information Extractor for YouTube playlists.""" - - _VALID_URL = r'(?:https?://)?(?:\w+\.)?youtube\.com/(?:(?:course|view_play_list|my_playlists|artist|playlist)\?.*?(p|a|list)=|user/.*?/user/|p/|user/.*?#[pg]/c/)(?:PL)?([0-9A-Za-z-_]+)(?:/.*?/([0-9A-Za-z_-]+))?.*' - _TEMPLATE_URL = 'http://www.youtube.com/%s?%s=%s&page=%s&gl=US&hl=en' - _VIDEO_INDICATOR_TEMPLATE = r'/watch\?v=(.+?)&list=PL%s&' - _MORE_PAGES_INDICATOR = r'(?m)>\s*Next\s*' - _youtube_ie = None - IE_NAME = u'youtube:playlist' - - def __init__(self, youtube_ie, downloader=None): - InfoExtractor.__init__(self, downloader) - self._youtube_ie = youtube_ie - - def report_download_page(self, playlist_id, pagenum): - """Report attempt to download playlist page with given number.""" - self._downloader.to_screen(u'[youtube] PL %s: Downloading page #%s' % (playlist_id, pagenum)) - - def _real_initialize(self): - self._youtube_ie.initialize() - - def _real_extract(self, url): - # Extract playlist id - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid url: %s' % url) - return - - # Single video case - if mobj.group(3) is not None: - self._youtube_ie.extract(mobj.group(3)) - return - - # Download playlist pages - # prefix is 'p' as default for playlists but there are other types that need extra care - playlist_prefix = mobj.group(1) - if playlist_prefix == 'a': - playlist_access = 'artist' - else: - playlist_prefix = 'p' - playlist_access = 'view_play_list' - playlist_id = mobj.group(2) - video_ids = [] - pagenum = 1 - - while True: - self.report_download_page(playlist_id, pagenum) - url = self._TEMPLATE_URL % (playlist_access, playlist_prefix, playlist_id, pagenum) - request = urllib2.Request(url) - try: - page = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download webpage: %s' % str(err)) - return - - # Extract video identifiers - ids_in_page = [] - for mobj in re.finditer(self._VIDEO_INDICATOR_TEMPLATE % playlist_id, page): - if mobj.group(1) not in ids_in_page: - ids_in_page.append(mobj.group(1)) - video_ids.extend(ids_in_page) - - if re.search(self._MORE_PAGES_INDICATOR, page) is None: - break - pagenum = pagenum + 1 - - playliststart = self._downloader.params.get('playliststart', 1) - 1 - playlistend = self._downloader.params.get('playlistend', -1) - if playlistend == -1: - video_ids = video_ids[playliststart:] - else: - video_ids = video_ids[playliststart:playlistend] - - for id in video_ids: - self._youtube_ie.extract('http://www.youtube.com/watch?v=%s' % id) - return - - -class YoutubeUserIE(InfoExtractor): - """Information Extractor for YouTube users.""" - - _VALID_URL = r'(?:(?:(?:https?://)?(?:\w+\.)?youtube\.com/user/)|ytuser:)([A-Za-z0-9_-]+)' - _TEMPLATE_URL = 'http://gdata.youtube.com/feeds/api/users/%s' - _GDATA_PAGE_SIZE = 50 - _GDATA_URL = 'http://gdata.youtube.com/feeds/api/users/%s/uploads?max-results=%d&start-index=%d' - _VIDEO_INDICATOR = r'/watch\?v=(.+?)[\<&]' - _youtube_ie = None - IE_NAME = u'youtube:user' - - def __init__(self, youtube_ie, downloader=None): - InfoExtractor.__init__(self, downloader) - self._youtube_ie = youtube_ie - - def report_download_page(self, username, start_index): - """Report attempt to download user page.""" - self._downloader.to_screen(u'[youtube] user %s: Downloading video ids from %d to %d' % - (username, start_index, start_index + self._GDATA_PAGE_SIZE)) - - def _real_initialize(self): - self._youtube_ie.initialize() - - def _real_extract(self, url): - # Extract username - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid url: %s' % url) - return - - username = mobj.group(1) - - # Download video ids using YouTube Data API. Result size per - # query is limited (currently to 50 videos) so we need to query - # page by page until there are no video ids - it means we got - # all of them. - - video_ids = [] - pagenum = 0 - - while True: - start_index = pagenum * self._GDATA_PAGE_SIZE + 1 - self.report_download_page(username, start_index) - - request = urllib2.Request(self._GDATA_URL % (username, self._GDATA_PAGE_SIZE, start_index)) - - try: - page = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download webpage: %s' % str(err)) - return - - # Extract video identifiers - ids_in_page = [] - - for mobj in re.finditer(self._VIDEO_INDICATOR, page): - if mobj.group(1) not in ids_in_page: - ids_in_page.append(mobj.group(1)) - - video_ids.extend(ids_in_page) - - # A little optimization - if current page is not - # "full", ie. does not contain PAGE_SIZE video ids then - # we can assume that this page is the last one - there - # are no more ids on further pages - no need to query - # again. - - if len(ids_in_page) < self._GDATA_PAGE_SIZE: - break - - pagenum += 1 - - all_ids_count = len(video_ids) - playliststart = self._downloader.params.get('playliststart', 1) - 1 - playlistend = self._downloader.params.get('playlistend', -1) - - if playlistend == -1: - video_ids = video_ids[playliststart:] - else: - video_ids = video_ids[playliststart:playlistend] - - self._downloader.to_screen(u"[youtube] user %s: Collected %d video ids (downloading %d of them)" % - (username, all_ids_count, len(video_ids))) - - for video_id in video_ids: - self._youtube_ie.extract('http://www.youtube.com/watch?v=%s' % video_id) - - -class DepositFilesIE(InfoExtractor): - """Information extractor for depositfiles.com""" - - _VALID_URL = r'(?:http://)?(?:\w+\.)?depositfiles\.com/(?:../(?#locale))?files/(.+)' - IE_NAME = u'DepositFiles' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_download_webpage(self, file_id): - """Report webpage download.""" - self._downloader.to_screen(u'[DepositFiles] %s: Downloading webpage' % file_id) - - def report_extraction(self, file_id): - """Report information extraction.""" - self._downloader.to_screen(u'[DepositFiles] %s: Extracting information' % file_id) - - def _real_extract(self, url): - # At this point we have a new file - self._downloader.increment_downloads() - - file_id = url.split('/')[-1] - # Rebuild url in english locale - url = 'http://depositfiles.com/en/files/' + file_id - - # Retrieve file webpage with 'Free download' button pressed - free_download_indication = { 'gateway_result' : '1' } - request = urllib2.Request(url, urllib.urlencode(free_download_indication)) - try: - self.report_download_webpage(file_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve file webpage: %s' % str(err)) - return - - # Search for the real file URL - mobj = re.search(r'(Attention.*?)', webpage, re.DOTALL) - if (mobj is not None) and (mobj.group(1) is not None): - restriction_message = re.sub('\s+', ' ', mobj.group(1)).strip() - self._downloader.trouble(u'ERROR: %s' % restriction_message) - else: - self._downloader.trouble(u'ERROR: unable to extract download URL from: %s' % url) - return - - file_url = mobj.group(1) - file_extension = os.path.splitext(file_url)[1][1:] - - # Search for file title - mobj = re.search(r'', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract title') - return - file_title = mobj.group(1).decode('utf-8') - - try: - # Process file information - self._downloader.process_info({ - 'id': file_id.decode('utf-8'), - 'url': file_url.decode('utf-8'), - 'uploader': u'NA', - 'upload_date': u'NA', - 'title': file_title, - 'stitle': file_title, - 'ext': file_extension.decode('utf-8'), - 'format': u'NA', - 'player_url': None, - }) - except UnavailableVideoError, err: - self._downloader.trouble(u'ERROR: unable to download file') - - -class FacebookIE(InfoExtractor): - """Information Extractor for Facebook""" - - _VALID_URL = r'^(?:https?://)?(?:\w+\.)?facebook\.com/(?:video/video|photo)\.php\?(?:.*?)v=(?P\d+)(?:.*)' - _LOGIN_URL = 'https://login.facebook.com/login.php?m&next=http%3A%2F%2Fm.facebook.com%2Fhome.php&' - _NETRC_MACHINE = 'facebook' - _available_formats = ['video', 'highqual', 'lowqual'] - _video_extensions = { - 'video': 'mp4', - 'highqual': 'mp4', - 'lowqual': 'mp4', - } - IE_NAME = u'facebook' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def _reporter(self, message): - """Add header and report message.""" - self._downloader.to_screen(u'[facebook] %s' % message) - - def report_login(self): - """Report attempt to log in.""" - self._reporter(u'Logging in') - - def report_video_webpage_download(self, video_id): - """Report attempt to download video webpage.""" - self._reporter(u'%s: Downloading video webpage' % video_id) - - def report_information_extraction(self, video_id): - """Report attempt to extract video information.""" - self._reporter(u'%s: Extracting video information' % video_id) - - def _parse_page(self, video_webpage): - """Extract video information from page""" - # General data - data = {'title': r'\("video_title", "(.*?)"\)', - 'description': r'
(.*?)
', - 'owner': r'\("video_owner_name", "(.*?)"\)', - 'thumbnail': r'\("thumb_url", "(?P.*?)"\)', - } - video_info = {} - for piece in data.keys(): - mobj = re.search(data[piece], video_webpage) - if mobj is not None: - video_info[piece] = urllib.unquote_plus(mobj.group(1).decode("unicode_escape")) - - # Video urls - video_urls = {} - for fmt in self._available_formats: - mobj = re.search(r'\("%s_src\", "(.+?)"\)' % fmt, video_webpage) - if mobj is not None: - # URL is in a Javascript segment inside an escaped Unicode format within - # the generally utf-8 page - video_urls[fmt] = urllib.unquote_plus(mobj.group(1).decode("unicode_escape")) - video_info['video_urls'] = video_urls - - return video_info - - def _real_initialize(self): - if self._downloader is None: - return - - useremail = None - password = None - downloader_params = self._downloader.params - - # Attempt to use provided username and password or .netrc data - if downloader_params.get('username', None) is not None: - useremail = downloader_params['username'] - password = downloader_params['password'] - elif downloader_params.get('usenetrc', False): - try: - info = netrc.netrc().authenticators(self._NETRC_MACHINE) - if info is not None: - useremail = info[0] - password = info[2] - else: - raise netrc.NetrcParseError('No authenticators for %s' % self._NETRC_MACHINE) - except (IOError, netrc.NetrcParseError), err: - self._downloader.to_stderr(u'WARNING: parsing .netrc: %s' % str(err)) - return - - if useremail is None: - return - - # Log in - login_form = { - 'email': useremail, - 'pass': password, - 'login': 'Log+In' - } - request = urllib2.Request(self._LOGIN_URL, urllib.urlencode(login_form)) - try: - self.report_login() - login_results = urllib2.urlopen(request).read() - if re.search(r'', login_results) is not None: - self._downloader.to_stderr(u'WARNING: unable to log in: bad username/password, or exceded login rate limit (~3/min). Check credentials or wait.') - return - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.to_stderr(u'WARNING: unable to log in: %s' % str(err)) - return - - def _real_extract(self, url): - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - video_id = mobj.group('ID') - - # Get video webpage - self.report_video_webpage_download(video_id) - request = urllib2.Request('https://www.facebook.com/video/video.php?v=%s' % video_id) - try: - page = urllib2.urlopen(request) - video_webpage = page.read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) - return - - # Start extracting information - self.report_information_extraction(video_id) - - # Extract information - video_info = self._parse_page(video_webpage) - - # uploader - if 'owner' not in video_info: - self._downloader.trouble(u'ERROR: unable to extract uploader nickname') - return - video_uploader = video_info['owner'] - - # title - if 'title' not in video_info: - self._downloader.trouble(u'ERROR: unable to extract video title') - return - video_title = video_info['title'] - video_title = video_title.decode('utf-8') - video_title = sanitize_title(video_title) - - simple_title = _simplify_title(video_title) - - # thumbnail image - if 'thumbnail' not in video_info: - self._downloader.trouble(u'WARNING: unable to extract video thumbnail') - video_thumbnail = '' - else: - video_thumbnail = video_info['thumbnail'] - - # upload date - upload_date = u'NA' - if 'upload_date' in video_info: - upload_time = video_info['upload_date'] - timetuple = email.utils.parsedate_tz(upload_time) - if timetuple is not None: - try: - upload_date = time.strftime('%Y%m%d', timetuple[0:9]) - except: - pass - - # description - video_description = video_info.get('description', 'No description available.') - - url_map = video_info['video_urls'] - if len(url_map.keys()) > 0: - # Decide which formats to download - req_format = self._downloader.params.get('format', None) - format_limit = self._downloader.params.get('format_limit', None) - - if format_limit is not None and format_limit in self._available_formats: - format_list = self._available_formats[self._available_formats.index(format_limit):] - else: - format_list = self._available_formats - existing_formats = [x for x in format_list if x in url_map] - if len(existing_formats) == 0: - self._downloader.trouble(u'ERROR: no known formats available for video') - return - if req_format is None: - video_url_list = [(existing_formats[0], url_map[existing_formats[0]])] # Best quality - elif req_format == 'worst': - video_url_list = [(existing_formats[len(existing_formats)-1], url_map[existing_formats[len(existing_formats)-1]])] # worst quality - elif req_format == '-1': - video_url_list = [(f, url_map[f]) for f in existing_formats] # All formats - else: - # Specific format - if req_format not in url_map: - self._downloader.trouble(u'ERROR: requested format not available') - return - video_url_list = [(req_format, url_map[req_format])] # Specific format - - for format_param, video_real_url in video_url_list: - - # At this point we have a new video - self._downloader.increment_downloads() - - # Extension - video_extension = self._video_extensions.get(format_param, 'mp4') - - try: - # Process video information - self._downloader.process_info({ - 'id': video_id.decode('utf-8'), - 'url': video_real_url.decode('utf-8'), - 'uploader': video_uploader.decode('utf-8'), - 'upload_date': upload_date, - 'title': video_title, - 'stitle': simple_title, - 'ext': video_extension.decode('utf-8'), - 'format': (format_param is None and u'NA' or format_param.decode('utf-8')), - 'thumbnail': video_thumbnail.decode('utf-8'), - 'description': video_description.decode('utf-8'), - 'player_url': None, - }) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download video') - -class BlipTVIE(InfoExtractor): - """Information extractor for blip.tv""" - - _VALID_URL = r'^(?:https?://)?(?:\w+\.)?blip\.tv(/.+)$' - _URL_EXT = r'^.*\.([a-z0-9]+)$' - IE_NAME = u'blip.tv' - - def report_extraction(self, file_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, file_id)) - - def report_direct_download(self, title): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Direct download detected' % (self.IE_NAME, title)) - - def _real_extract(self, url): - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - - if '?' in url: - cchar = '&' - else: - cchar = '?' - json_url = url + cchar + 'skin=json&version=2&no_wrap=1' - request = urllib2.Request(json_url) - self.report_extraction(mobj.group(1)) - info = None - try: - urlh = urllib2.urlopen(request) - if urlh.headers.get('Content-Type', '').startswith('video/'): # Direct download - basename = url.split('/')[-1] - title,ext = os.path.splitext(basename) - title = title.decode('UTF-8') - ext = ext.replace('.', '') - self.report_direct_download(title) - info = { - 'id': title, - 'url': url, - 'title': title, - 'stitle': _simplify_title(title), - 'ext': ext, - 'urlhandle': urlh - } - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video info webpage: %s' % str(err)) - return - if info is None: # Regular URL - try: - json_code = urlh.read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to read video info webpage: %s' % str(err)) - return - - try: - json_data = json.loads(json_code) - if 'Post' in json_data: - data = json_data['Post'] - else: - data = json_data - - upload_date = datetime.datetime.strptime(data['datestamp'], '%m-%d-%y %H:%M%p').strftime('%Y%m%d') - video_url = data['media']['url'] - umobj = re.match(self._URL_EXT, video_url) - if umobj is None: - raise ValueError('Can not determine filename extension') - ext = umobj.group(1) - - info = { - 'id': data['item_id'], - 'url': video_url, - 'uploader': data['display_name'], - 'upload_date': upload_date, - 'title': data['title'], - 'stitle': _simplify_title(data['title']), - 'ext': ext, - 'format': data['media']['mimeType'], - 'thumbnail': data['thumbnailUrl'], - 'description': data['description'], - 'player_url': data['embedUrl'] - } - except (ValueError,KeyError), err: - self._downloader.trouble(u'ERROR: unable to parse video information: %s' % repr(err)) - return - - self._downloader.increment_downloads() - - try: - self._downloader.process_info(info) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class MyVideoIE(InfoExtractor): - """Information Extractor for myvideo.de.""" - - _VALID_URL = r'(?:http://)?(?:www\.)?myvideo\.de/watch/([0-9]+)/([^?/]+).*' - IE_NAME = u'myvideo' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_download_webpage(self, video_id): - """Report webpage download.""" - self._downloader.to_screen(u'[myvideo] %s: Downloading webpage' % video_id) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[myvideo] %s: Extracting information' % video_id) - - def _real_extract(self,url): - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._download.trouble(u'ERROR: invalid URL: %s' % url) - return - - video_id = mobj.group(1) - - # Get video webpage - request = urllib2.Request('http://www.myvideo.de/watch/%s' % video_id) - try: - self.report_download_webpage(video_id) - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve video webpage: %s' % str(err)) - return - - self.report_extraction(video_id) - mobj = re.search(r'', - webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract media URL') - return - video_url = mobj.group(1) + ('/%s.flv' % video_id) - - mobj = re.search('([^<]+)', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract title') - return - - video_title = mobj.group(1) - video_title = sanitize_title(video_title) - - simple_title = _simplify_title(video_title) - - try: - self._downloader.process_info({ - 'id': video_id, - 'url': video_url, - 'uploader': u'NA', - 'upload_date': u'NA', - 'title': video_title, - 'stitle': simple_title, - 'ext': u'flv', - 'format': u'NA', - 'player_url': None, - }) - except UnavailableVideoError: - self._downloader.trouble(u'\nERROR: Unable to download video') - -class ComedyCentralIE(InfoExtractor): - """Information extractor for The Daily Show and Colbert Report """ - - _VALID_URL = r'^(:(?Ptds|thedailyshow|cr|colbert|colbertnation|colbertreport))|(https?://)?(www\.)?(?Pthedailyshow|colbertnation)\.com/full-episodes/(?P.*)$' - IE_NAME = u'comedycentral' - - def report_extraction(self, episode_id): - self._downloader.to_screen(u'[comedycentral] %s: Extracting information' % episode_id) - - def report_config_download(self, episode_id): - self._downloader.to_screen(u'[comedycentral] %s: Downloading configuration' % episode_id) - - def report_index_download(self, episode_id): - self._downloader.to_screen(u'[comedycentral] %s: Downloading show index' % episode_id) - - def report_player_url(self, episode_id): - self._downloader.to_screen(u'[comedycentral] %s: Determining player URL' % episode_id) - - def _real_extract(self, url): - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - - if mobj.group('shortname'): - if mobj.group('shortname') in ('tds', 'thedailyshow'): - url = u'http://www.thedailyshow.com/full-episodes/' - else: - url = u'http://www.colbertnation.com/full-episodes/' - mobj = re.match(self._VALID_URL, url) - assert mobj is not None - - dlNewest = not mobj.group('episode') - if dlNewest: - epTitle = mobj.group('showname') - else: - epTitle = mobj.group('episode') - - req = urllib2.Request(url) - self.report_extraction(epTitle) - try: - htmlHandle = urllib2.urlopen(req) - html = htmlHandle.read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download webpage: %s' % unicode(err)) - return - if dlNewest: - url = htmlHandle.geturl() - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: Invalid redirected URL: ' + url) - return - if mobj.group('episode') == '': - self._downloader.trouble(u'ERROR: Redirected URL is still not specific: ' + url) - return - epTitle = mobj.group('episode') - - mMovieParams = re.findall('(?:[^/]+)/(?P[^/?]+)[/?]?.*$' - IE_NAME = u'escapist' - - def report_extraction(self, showName): - self._downloader.to_screen(u'[escapist] %s: Extracting information' % showName) - - def report_config_download(self, showName): - self._downloader.to_screen(u'[escapist] %s: Downloading configuration' % showName) - - def _real_extract(self, url): - htmlParser = HTMLParser.HTMLParser() - - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - showName = mobj.group('showname') - videoId = mobj.group('episode') - - self.report_extraction(showName) - try: - webPage = urllib2.urlopen(url).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download webpage: ' + unicode(err)) - return - - descMatch = re.search('[0-9]+)/(?P.*)$' - IE_NAME = u'collegehumor' - - def report_webpage(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) - - def _real_extract(self, url): - htmlParser = HTMLParser.HTMLParser() - - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - video_id = mobj.group('videoid') - - self.report_webpage(video_id) - request = urllib2.Request(url) - try: - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) - return - - m = re.search(r'id="video:(?P[0-9]+)"', webpage) - if m is None: - self._downloader.trouble(u'ERROR: Cannot extract internal video ID') - return - internal_video_id = m.group('internalvideoid') - - info = { - 'id': video_id, - 'internal_id': internal_video_id, - } - - self.report_extraction(video_id) - xmlUrl = 'http://www.collegehumor.com/moogaloop/video:' + internal_video_id - try: - metaXml = urllib2.urlopen(xmlUrl).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video info XML: %s' % str(err)) - return - - mdoc = xml.etree.ElementTree.fromstring(metaXml) - try: - videoNode = mdoc.findall('./video')[0] - info['description'] = videoNode.findall('./description')[0].text - info['title'] = videoNode.findall('./caption')[0].text - info['stitle'] = _simplify_title(info['title']) - info['url'] = videoNode.findall('./file')[0].text - info['thumbnail'] = videoNode.findall('./thumbnail')[0].text - info['ext'] = info['url'].rpartition('.')[2] - info['format'] = info['ext'] - except IndexError: - self._downloader.trouble(u'\nERROR: Invalid metadata XML file') - return - - self._downloader.increment_downloads() - - try: - self._downloader.process_info(info) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class XVideosIE(InfoExtractor): - """Information extractor for xvideos.com""" - - _VALID_URL = r'^(?:https?://)?(?:www\.)?xvideos\.com/video([0-9]+)(?:.*)' - IE_NAME = u'xvideos' - - def report_webpage(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) - - def _real_extract(self, url): - htmlParser = HTMLParser.HTMLParser() - - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - video_id = mobj.group(1).decode('utf-8') - - self.report_webpage(video_id) - - request = urllib2.Request(r'http://www.xvideos.com/video' + video_id) - try: - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) - return - - self.report_extraction(video_id) - - - # Extract video URL - mobj = re.search(r'flv_url=(.+?)&', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video url') - return - video_url = urllib2.unquote(mobj.group(1).decode('utf-8')) - - - # Extract title - mobj = re.search(r'(.*?)\s+-\s+XVID', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video title') - return - video_title = mobj.group(1).decode('utf-8') - - - # Extract video thumbnail - mobj = re.search(r'http://(?:img.*?\.)xvideos.com/videos/thumbs/[a-fA-F0-9]/[a-fA-F0-9]/[a-fA-F0-9]/([a-fA-F0-9.]+jpg)', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video thumbnail') - return - video_thumbnail = mobj.group(1).decode('utf-8') - - - - self._downloader.increment_downloads() - info = { - 'id': video_id, - 'url': video_url, - 'uploader': None, - 'upload_date': None, - 'title': video_title, - 'stitle': _simplify_title(video_title), - 'ext': 'flv', - 'format': 'flv', - 'thumbnail': video_thumbnail, - 'description': None, - 'player_url': None, - } - - try: - self._downloader.process_info(info) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download ' + video_id) - - -class SoundcloudIE(InfoExtractor): - """Information extractor for soundcloud.com - To access the media, the uid of the song and a stream token - must be extracted from the page source and the script must make - a request to media.soundcloud.com/crossdomain.xml. Then - the media can be grabbed by requesting from an url composed - of the stream token and uid - """ - - _VALID_URL = r'^(?:https?://)?(?:www\.)?soundcloud\.com/([\w\d-]+)/([\w\d-]+)' - IE_NAME = u'soundcloud' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_webpage(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) - - def _real_extract(self, url): - htmlParser = HTMLParser.HTMLParser() - - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - - # extract uploader (which is in the url) - uploader = mobj.group(1).decode('utf-8') - # extract simple title (uploader + slug of song title) - slug_title = mobj.group(2).decode('utf-8') - simple_title = uploader + '-' + slug_title - - self.report_webpage('%s/%s' % (uploader, slug_title)) - - request = urllib2.Request('http://soundcloud.com/%s/%s' % (uploader, slug_title)) - try: - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) - return - - self.report_extraction('%s/%s' % (uploader, slug_title)) - - # extract uid and stream token that soundcloud hands out for access - mobj = re.search('"uid":"([\w\d]+?)".*?stream_token=([\w\d]+)', webpage) - if mobj: - video_id = mobj.group(1) - stream_token = mobj.group(2) - - # extract unsimplified title - mobj = re.search('"title":"(.*?)",', webpage) - if mobj: - title = mobj.group(1) - - # construct media url (with uid/token) - mediaURL = "http://media.soundcloud.com/stream/%s?stream_token=%s" - mediaURL = mediaURL % (video_id, stream_token) - - # description - description = u'No description available' - mobj = re.search('track-description-value"><p>(.*?)</p>', webpage) - if mobj: - description = mobj.group(1) - - # upload date - upload_date = None - mobj = re.search("pretty-date'>on ([\w]+ [\d]+, [\d]+ \d+:\d+)</abbr></h2>", webpage) - if mobj: - try: - upload_date = datetime.datetime.strptime(mobj.group(1), '%B %d, %Y %H:%M').strftime('%Y%m%d') - except Exception, e: - print str(e) - - # for soundcloud, a request to a cross domain is required for cookies - request = urllib2.Request('http://media.soundcloud.com/crossdomain.xml', std_headers) - - try: - self._downloader.process_info({ - 'id': video_id.decode('utf-8'), - 'url': mediaURL, - 'uploader': uploader.decode('utf-8'), - 'upload_date': upload_date, - 'title': simple_title.decode('utf-8'), - 'stitle': simple_title.decode('utf-8'), - 'ext': u'mp3', - 'format': u'NA', - 'player_url': None, - 'description': description.decode('utf-8') - }) - except UnavailableVideoError: - self._downloader.trouble(u'\nERROR: unable to download video') - - -class InfoQIE(InfoExtractor): - """Information extractor for infoq.com""" - - _VALID_URL = r'^(?:https?://)?(?:www\.)?infoq\.com/[^/]+/[^/]+$' - IE_NAME = u'infoq' - - def report_webpage(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) - - def _real_extract(self, url): - htmlParser = HTMLParser.HTMLParser() - - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - - self.report_webpage(url) - - request = urllib2.Request(url) - try: - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) - return - - self.report_extraction(url) - - - # Extract video URL - mobj = re.search(r"jsclassref='([^']*)'", webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video url') - return - video_url = 'rtmpe://video.infoq.com/cfx/st/' + urllib2.unquote(mobj.group(1).decode('base64')) - - - # Extract title - mobj = re.search(r'contentTitle = "(.*?)";', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract video title') - return - video_title = mobj.group(1).decode('utf-8') - - # Extract description - video_description = u'No description available.' - mobj = re.search(r'<meta name="description" content="(.*)"(?:\s*/)?>', webpage) - if mobj is not None: - video_description = mobj.group(1).decode('utf-8') - - video_filename = video_url.split('/')[-1] - video_id, extension = video_filename.split('.') - - self._downloader.increment_downloads() - info = { - 'id': video_id, - 'url': video_url, - 'uploader': None, - 'upload_date': None, - 'title': video_title, - 'stitle': _simplify_title(video_title), - 'ext': extension, - 'format': extension, # Extension is always(?) mp4, but seems to be flv - 'thumbnail': None, - 'description': video_description, - 'player_url': None, - } - - try: - self._downloader.process_info(info) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download ' + video_url) - -class MixcloudIE(InfoExtractor): - """Information extractor for www.mixcloud.com""" - _VALID_URL = r'^(?:https?://)?(?:www\.)?mixcloud\.com/([\w\d-]+)/([\w\d-]+)' - IE_NAME = u'mixcloud' - - def __init__(self, downloader=None): - InfoExtractor.__init__(self, downloader) - - def report_download_json(self, file_id): - """Report JSON download.""" - self._downloader.to_screen(u'[%s] Downloading json' % self.IE_NAME) - - def report_extraction(self, file_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, file_id)) - - def get_urls(self, jsonData, fmt, bitrate='best'): - """Get urls from 'audio_formats' section in json""" - file_url = None - try: - bitrate_list = jsonData[fmt] - if bitrate is None or bitrate == 'best' or bitrate not in bitrate_list: - bitrate = max(bitrate_list) # select highest - - url_list = jsonData[fmt][bitrate] - except TypeError: # we have no bitrate info. - url_list = jsonData[fmt] - - return url_list - - def check_urls(self, url_list): - """Returns 1st active url from list""" - for url in url_list: - try: - urllib2.urlopen(url) - return url - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - url = None - - return None - - def _print_formats(self, formats): - print 'Available formats:' - for fmt in formats.keys(): - for b in formats[fmt]: - try: - ext = formats[fmt][b][0] - print '%s\t%s\t[%s]' % (fmt, b, ext.split('.')[-1]) - except TypeError: # we have no bitrate info - ext = formats[fmt][0] - print '%s\t%s\t[%s]' % (fmt, '??', ext.split('.')[-1]) - break - - def _real_extract(self, url): - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - # extract uploader & filename from url - uploader = mobj.group(1).decode('utf-8') - file_id = uploader + "-" + mobj.group(2).decode('utf-8') - - # construct API request - file_url = 'http://www.mixcloud.com/api/1/cloudcast/' + '/'.join(url.split('/')[-3:-1]) + '.json' - # retrieve .json file with links to files - request = urllib2.Request(file_url) - try: - self.report_download_json(file_url) - jsonData = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: Unable to retrieve file: %s' % str(err)) - return - - # parse JSON - json_data = json.loads(jsonData) - player_url = json_data['player_swf_url'] - formats = dict(json_data['audio_formats']) - - req_format = self._downloader.params.get('format', None) - bitrate = None - - if self._downloader.params.get('listformats', None): - self._print_formats(formats) - return - - if req_format is None or req_format == 'best': - for format_param in formats.keys(): - url_list = self.get_urls(formats, format_param) - # check urls - file_url = self.check_urls(url_list) - if file_url is not None: - break # got it! - else: - if req_format not in formats.keys(): - self._downloader.trouble(u'ERROR: format is not available') - return - - url_list = self.get_urls(formats, req_format) - file_url = self.check_urls(url_list) - format_param = req_format - - # We have audio - self._downloader.increment_downloads() - try: - # Process file information - self._downloader.process_info({ - 'id': file_id.decode('utf-8'), - 'url': file_url.decode('utf-8'), - 'uploader': uploader.decode('utf-8'), - 'upload_date': u'NA', - 'title': json_data['name'], - 'stitle': _simplify_title(json_data['name']), - 'ext': file_url.split('.')[-1].decode('utf-8'), - 'format': (format_param is None and u'NA' or format_param.decode('utf-8')), - 'thumbnail': json_data['thumbnail_url'], - 'description': json_data['description'], - 'player_url': player_url.decode('utf-8'), - }) - except UnavailableVideoError, err: - self._downloader.trouble(u'ERROR: unable to download file') - -class StanfordOpenClassroomIE(InfoExtractor): - """Information extractor for Stanford's Open ClassRoom""" - - _VALID_URL = r'^(?:https?://)?openclassroom.stanford.edu(?P<path>/?|(/MainFolder/(?:HomePage|CoursePage|VideoPage)\.php([?]course=(?P<course>[^&]+)(&video=(?P<video>[^&]+))?(&.*)?)?))$' - IE_NAME = u'stanfordoc' - - def report_download_webpage(self, objid): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, objid)) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) - - def _real_extract(self, url): - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - - if mobj.group('course') and mobj.group('video'): # A specific video - course = mobj.group('course') - video = mobj.group('video') - info = { - 'id': _simplify_title(course + '_' + video), - } - - self.report_extraction(info['id']) - baseUrl = 'http://openclassroom.stanford.edu/MainFolder/courses/' + course + '/videos/' - xmlUrl = baseUrl + video + '.xml' - try: - metaXml = urllib2.urlopen(xmlUrl).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video info XML: %s' % unicode(err)) - return - mdoc = xml.etree.ElementTree.fromstring(metaXml) - try: - info['title'] = mdoc.findall('./title')[0].text - info['url'] = baseUrl + mdoc.findall('./videoFile')[0].text - except IndexError: - self._downloader.trouble(u'\nERROR: Invalid metadata XML file') - return - info['stitle'] = _simplify_title(info['title']) - info['ext'] = info['url'].rpartition('.')[2] - info['format'] = info['ext'] - self._downloader.increment_downloads() - try: - self._downloader.process_info(info) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download video') - elif mobj.group('course'): # A course page - unescapeHTML = HTMLParser.HTMLParser().unescape - - course = mobj.group('course') - info = { - 'id': _simplify_title(course), - 'type': 'playlist', - } - - self.report_download_webpage(info['id']) - try: - coursepage = urllib2.urlopen(url).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download course info page: ' + unicode(err)) - return - - m = re.search('<h1>([^<]+)</h1>', coursepage) - if m: - info['title'] = unescapeHTML(m.group(1)) - else: - info['title'] = info['id'] - info['stitle'] = _simplify_title(info['title']) - - m = re.search('<description>([^<]+)</description>', coursepage) - if m: - info['description'] = unescapeHTML(m.group(1)) - - links = _orderedSet(re.findall('<a href="(VideoPage.php\?[^"]+)">', coursepage)) - info['list'] = [ - { - 'type': 'reference', - 'url': 'http://openclassroom.stanford.edu/MainFolder/' + unescapeHTML(vpage), - } - for vpage in links] - - for entry in info['list']: - assert entry['type'] == 'reference' - self.extract(entry['url']) - else: # Root page - unescapeHTML = HTMLParser.HTMLParser().unescape - - info = { - 'id': 'Stanford OpenClassroom', - 'type': 'playlist', - } - - self.report_download_webpage(info['id']) - rootURL = 'http://openclassroom.stanford.edu/MainFolder/HomePage.php' - try: - rootpage = urllib2.urlopen(rootURL).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download course info page: ' + unicode(err)) - return - - info['title'] = info['id'] - info['stitle'] = _simplify_title(info['title']) - - links = _orderedSet(re.findall('<a href="(CoursePage.php\?[^"]+)">', rootpage)) - info['list'] = [ - { - 'type': 'reference', - 'url': 'http://openclassroom.stanford.edu/MainFolder/' + unescapeHTML(cpage), - } - for cpage in links] - - for entry in info['list']: - assert entry['type'] == 'reference' - self.extract(entry['url']) - -class MTVIE(InfoExtractor): - """Information extractor for MTV.com""" - - _VALID_URL = r'^(?P<proto>https?://)?(?:www\.)?mtv\.com/videos/[^/]+/(?P<videoid>[0-9]+)/[^/]+$' - IE_NAME = u'mtv' - - def report_webpage(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Downloading webpage' % (self.IE_NAME, video_id)) - - def report_extraction(self, video_id): - """Report information extraction.""" - self._downloader.to_screen(u'[%s] %s: Extracting information' % (self.IE_NAME, video_id)) - - def _real_extract(self, url): - mobj = re.match(self._VALID_URL, url) - if mobj is None: - self._downloader.trouble(u'ERROR: invalid URL: %s' % url) - return - if not mobj.group('proto'): - url = 'http://' + url - video_id = mobj.group('videoid') - self.report_webpage(video_id) - - request = urllib2.Request(url) - try: - webpage = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video webpage: %s' % str(err)) - return - - mobj = re.search(r'<meta name="mtv_vt" content="([^"]+)"/>', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract song name') - return - song_name = _unescapeHTML(mobj.group(1).decode('iso-8859-1')) - mobj = re.search(r'<meta name="mtv_an" content="([^"]+)"/>', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract performer') - return - performer = _unescapeHTML(mobj.group(1).decode('iso-8859-1')) - video_title = performer + ' - ' + song_name - - mobj = re.search(r'<meta name="mtvn_uri" content="([^"]+)"/>', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to mtvn_uri') - return - mtvn_uri = mobj.group(1) - - mobj = re.search(r'MTVN.Player.defaultPlaylistId = ([0-9]+);', webpage) - if mobj is None: - self._downloader.trouble(u'ERROR: unable to extract content id') - return - content_id = mobj.group(1) - - videogen_url = 'http://www.mtv.com/player/includes/mediaGen.jhtml?uri=' + mtvn_uri + '&id=' + content_id + '&vid=' + video_id + '&ref=www.mtvn.com&viewUri=' + mtvn_uri - self.report_extraction(video_id) - request = urllib2.Request(videogen_url) - try: - metadataXml = urllib2.urlopen(request).read() - except (urllib2.URLError, httplib.HTTPException, socket.error), err: - self._downloader.trouble(u'ERROR: unable to download video metadata: %s' % str(err)) - return - - mdoc = xml.etree.ElementTree.fromstring(metadataXml) - renditions = mdoc.findall('.//rendition') - - # For now, always pick the highest quality. - rendition = renditions[-1] - - try: - _,_,ext = rendition.attrib['type'].partition('/') - format = ext + '-' + rendition.attrib['width'] + 'x' + rendition.attrib['height'] + '_' + rendition.attrib['bitrate'] - video_url = rendition.find('./src').text - except KeyError: - self._downloader.trouble('Invalid rendition field.') - return - - self._downloader.increment_downloads() - info = { - 'id': video_id, - 'url': video_url, - 'uploader': performer, - 'title': video_title, - 'stitle': _simplify_title(video_title), - 'ext': ext, - 'format': format, - } - - try: - self._downloader.process_info(info) - except UnavailableVideoError, err: - self._downloader.trouble(u'\nERROR: unable to download ' + video_id) - - -class PostProcessor(object): - """Post Processor class. - - PostProcessor objects can be added to downloaders with their - add_post_processor() method. When the downloader has finished a - successful download, it will take its internal chain of PostProcessors - and start calling the run() method on each one of them, first with - an initial argument and then with the returned value of the previous - PostProcessor. - - The chain will be stopped if one of them ever returns None or the end - of the chain is reached. - - PostProcessor objects follow a "mutual registration" process similar - to InfoExtractor objects. - """ - - _downloader = None - - def __init__(self, downloader=None): - self._downloader = downloader - - def set_downloader(self, downloader): - """Sets the downloader for this PP.""" - self._downloader = downloader - - def run(self, information): - """Run the PostProcessor. - - The "information" argument is a dictionary like the ones - composed by InfoExtractors. The only difference is that this - one has an extra field called "filepath" that points to the - downloaded file. - - When this method returns None, the postprocessing chain is - stopped. However, this method may return an information - dictionary that will be passed to the next postprocessing - object in the chain. It can be the one it received after - changing some fields. - - In addition, this method may raise a PostProcessingError - exception that will be taken into account by the downloader - it was called from. - """ - return information # by default, do nothing - -class AudioConversionError(BaseException): - def __init__(self, message): - self.message = message - -class FFmpegExtractAudioPP(PostProcessor): - - def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, keepvideo=False): - PostProcessor.__init__(self, downloader) - if preferredcodec is None: - preferredcodec = 'best' - self._preferredcodec = preferredcodec - self._preferredquality = preferredquality - self._keepvideo = keepvideo - - @staticmethod - def get_audio_codec(path): - try: - cmd = ['ffprobe', '-show_streams', '--', _encodeFilename(path)] - handle = subprocess.Popen(cmd, stderr=file(os.path.devnull, 'w'), stdout=subprocess.PIPE) - output = handle.communicate()[0] - if handle.wait() != 0: - return None - except (IOError, OSError): - return None - audio_codec = None - for line in output.split('\n'): - if line.startswith('codec_name='): - audio_codec = line.split('=')[1].strip() - elif line.strip() == 'codec_type=audio' and audio_codec is not None: - return audio_codec - return None - - @staticmethod - def run_ffmpeg(path, out_path, codec, more_opts): - if codec is None: - acodec_opts = [] - else: - acodec_opts = ['-acodec', codec] - cmd = ['ffmpeg', '-y', '-i', _encodeFilename(path), '-vn'] + acodec_opts + more_opts + ['--', _encodeFilename(out_path)] - try: - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout,stderr = p.communicate() - except (IOError, OSError): - e = sys.exc_info()[1] - if isinstance(e, OSError) and e.errno == 2: - raise AudioConversionError('ffmpeg not found. Please install ffmpeg.') - else: - raise e - if p.returncode != 0: - msg = stderr.strip().split('\n')[-1] - raise AudioConversionError(msg) - - def run(self, information): - path = information['filepath'] - - filecodec = self.get_audio_codec(path) - if filecodec is None: - self._downloader.to_stderr(u'WARNING: unable to obtain file audio codec with ffprobe') - return None - - more_opts = [] - if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'): - if self._preferredcodec == 'm4a' and filecodec == 'aac': - # Lossless, but in another container - acodec = 'copy' - extension = self._preferredcodec - more_opts = ['-absf', 'aac_adtstoasc'] - elif filecodec in ['aac', 'mp3', 'vorbis']: - # Lossless if possible - acodec = 'copy' - extension = filecodec - if filecodec == 'aac': - more_opts = ['-f', 'adts'] - if filecodec == 'vorbis': - extension = 'ogg' - else: - # MP3 otherwise. - acodec = 'libmp3lame' - extension = 'mp3' - more_opts = [] - if self._preferredquality is not None: - more_opts += ['-ab', self._preferredquality] - else: - # We convert the audio (lossy) - acodec = {'mp3': 'libmp3lame', 'aac': 'aac', 'm4a': 'aac', 'vorbis': 'libvorbis', 'wav': None}[self._preferredcodec] - extension = self._preferredcodec - more_opts = [] - if self._preferredquality is not None: - more_opts += ['-ab', self._preferredquality] - if self._preferredcodec == 'aac': - more_opts += ['-f', 'adts'] - if self._preferredcodec == 'm4a': - more_opts += ['-absf', 'aac_adtstoasc'] - if self._preferredcodec == 'vorbis': - extension = 'ogg' - if self._preferredcodec == 'wav': - extension = 'wav' - more_opts += ['-f', 'wav'] - - prefix, sep, ext = path.rpartition(u'.') # not os.path.splitext, since the latter does not work on unicode in all setups - new_path = prefix + sep + extension - self._downloader.to_screen(u'[ffmpeg] Destination: ' + new_path) - try: - self.run_ffmpeg(path, new_path, acodec, more_opts) - except: - etype,e,tb = sys.exc_info() - if isinstance(e, AudioConversionError): - self._downloader.to_stderr(u'ERROR: audio conversion failed: ' + e.message) - else: - self._downloader.to_stderr(u'ERROR: error running ffmpeg') - return None - - # Try to update the date time for extracted audio file. - if information.get('filetime') is not None: - try: - os.utime(_encodeFilename(new_path), (time.time(), information['filetime'])) - except: - self._downloader.to_stderr(u'WARNING: Cannot update utime of audio file') - - if not self._keepvideo: - try: - os.remove(_encodeFilename(path)) - except (IOError, OSError): - self._downloader.to_stderr(u'WARNING: Unable to remove downloaded video file') - return None - - information['filepath'] = new_path - return information +from Utils import * +from FileDownloader import * +from InfoExtractors import * +from PostProcessing import * def updateSelf(downloader, filename): ''' Update the program file with the latest version from the repository ''' @@ -4324,7 +51,7 @@ def updateSelf(downloader, filename): try: try: - urlh = urllib.urlopen(UPDATE_URL) + urlh = urllib2.urlopen(UPDATE_URL) newcontent = urlh.read() vmatch = re.search("__version__ = '([^']+)'", newcontent) @@ -4781,8 +508,3 @@ def main(): sys.exit(u'ERROR: fixed output name but more than one file to download') except KeyboardInterrupt: sys.exit(u'\nERROR: Interrupted by user') - -if __name__ == '__main__': - main() - -# vim: set ts=4 sw=4 sts=4 noet ai si filetype=python: diff --git a/youtube_dl/__main__.py b/youtube_dl/__main__.py new file mode 100755 index 0000000000..6f20402e24 --- /dev/null +++ b/youtube_dl/__main__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import __init__ + +if __name__ == '__main__': + __init__.main()