Merge branch 'master' into subtitles_rework
[youtube-dl] / youtube_dl / PostProcessor.py
1 import os
2 import subprocess
3 import sys
4 import time
5
6 from .utils import *
7
8
9 class PostProcessor(object):
10     """Post Processor class.
11
12     PostProcessor objects can be added to downloaders with their
13     add_post_processor() method. When the downloader has finished a
14     successful download, it will take its internal chain of PostProcessors
15     and start calling the run() method on each one of them, first with
16     an initial argument and then with the returned value of the previous
17     PostProcessor.
18
19     The chain will be stopped if one of them ever returns None or the end
20     of the chain is reached.
21
22     PostProcessor objects follow a "mutual registration" process similar
23     to InfoExtractor objects.
24     """
25
26     _downloader = None
27
28     def __init__(self, downloader=None):
29         self._downloader = downloader
30
31     def set_downloader(self, downloader):
32         """Sets the downloader for this PP."""
33         self._downloader = downloader
34
35     def run(self, information):
36         """Run the PostProcessor.
37
38         The "information" argument is a dictionary like the ones
39         composed by InfoExtractors. The only difference is that this
40         one has an extra field called "filepath" that points to the
41         downloaded file.
42
43         This method returns a tuple, the first element of which describes
44         whether the original file should be kept (i.e. not deleted - None for
45         no preference), and the second of which is the updated information.
46
47         In addition, this method may raise a PostProcessingError
48         exception if post processing fails.
49         """
50         return None, information # by default, keep file and do nothing
51
52 class FFmpegPostProcessorError(PostProcessingError):
53     pass
54
55 class AudioConversionError(PostProcessingError):
56     pass
57
58 class FFmpegPostProcessor(PostProcessor):
59     def __init__(self,downloader=None):
60         PostProcessor.__init__(self, downloader)
61         self._exes = self.detect_executables()
62
63     @staticmethod
64     def detect_executables():
65         def executable(exe):
66             try:
67                 subprocess.Popen([exe, '-version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
68             except OSError:
69                 return False
70             return exe
71         programs = ['avprobe', 'avconv', 'ffmpeg', 'ffprobe']
72         return dict((program, executable(program)) for program in programs)
73
74     def run_ffmpeg(self, path, out_path, opts):
75         if not self._exes['ffmpeg'] and not self._exes['avconv']:
76             raise FFmpegPostProcessorError(u'ffmpeg or avconv not found. Please install one.')
77         cmd = ([self._exes['avconv'] or self._exes['ffmpeg'], '-y', '-i', encodeFilename(path)]
78                + opts +
79                [encodeFilename(self._ffmpeg_filename_argument(out_path))])
80         p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
81         stdout,stderr = p.communicate()
82         if p.returncode != 0:
83             stderr = stderr.decode('utf-8', 'replace')
84             msg = stderr.strip().split('\n')[-1]
85             raise FFmpegPostProcessorError(msg)
86
87     def _ffmpeg_filename_argument(self, fn):
88         # ffmpeg broke --, see https://ffmpeg.org/trac/ffmpeg/ticket/2127 for details
89         if fn.startswith(u'-'):
90             return u'./' + fn
91         return fn
92
93 class FFmpegExtractAudioPP(FFmpegPostProcessor):
94     def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False):
95         FFmpegPostProcessor.__init__(self, downloader)
96         if preferredcodec is None:
97             preferredcodec = 'best'
98         self._preferredcodec = preferredcodec
99         self._preferredquality = preferredquality
100         self._nopostoverwrites = nopostoverwrites
101
102     def get_audio_codec(self, path):
103         if not self._exes['ffprobe'] and not self._exes['avprobe']:
104             raise PostProcessingError(u'ffprobe or avprobe not found. Please install one.')
105         try:
106             cmd = [self._exes['avprobe'] or self._exes['ffprobe'], '-show_streams', encodeFilename(self._ffmpeg_filename_argument(path))]
107             handle = subprocess.Popen(cmd, stderr=compat_subprocess_get_DEVNULL(), stdout=subprocess.PIPE)
108             output = handle.communicate()[0]
109             if handle.wait() != 0:
110                 return None
111         except (IOError, OSError):
112             return None
113         audio_codec = None
114         for line in output.decode('ascii', 'ignore').split('\n'):
115             if line.startswith('codec_name='):
116                 audio_codec = line.split('=')[1].strip()
117             elif line.strip() == 'codec_type=audio' and audio_codec is not None:
118                 return audio_codec
119         return None
120
121     def run_ffmpeg(self, path, out_path, codec, more_opts):
122         if not self._exes['ffmpeg'] and not self._exes['avconv']:
123             raise AudioConversionError('ffmpeg or avconv not found. Please install one.')
124         if codec is None:
125             acodec_opts = []
126         else:
127             acodec_opts = ['-acodec', codec]
128         opts = ['-vn'] + acodec_opts + more_opts
129         try:
130             FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts)
131         except FFmpegPostProcessorError as err:
132             raise AudioConversionError(err.message)
133
134     def run(self, information):
135         path = information['filepath']
136
137         filecodec = self.get_audio_codec(path)
138         if filecodec is None:
139             raise PostProcessingError(u'WARNING: unable to obtain file audio codec with ffprobe')
140
141         more_opts = []
142         if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
143             if filecodec == 'aac' and self._preferredcodec in ['m4a', 'best']:
144                 # Lossless, but in another container
145                 acodec = 'copy'
146                 extension = 'm4a'
147                 more_opts = [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc']
148             elif filecodec in ['aac', 'mp3', 'vorbis', 'opus']:
149                 # Lossless if possible
150                 acodec = 'copy'
151                 extension = filecodec
152                 if filecodec == 'aac':
153                     more_opts = ['-f', 'adts']
154                 if filecodec == 'vorbis':
155                     extension = 'ogg'
156             else:
157                 # MP3 otherwise.
158                 acodec = 'libmp3lame'
159                 extension = 'mp3'
160                 more_opts = []
161                 if self._preferredquality is not None:
162                     if int(self._preferredquality) < 10:
163                         more_opts += [self._exes['avconv'] and '-q:a' or '-aq', self._preferredquality]
164                     else:
165                         more_opts += [self._exes['avconv'] and '-b:a' or '-ab', self._preferredquality + 'k']
166         else:
167             # We convert the audio (lossy)
168             acodec = {'mp3': 'libmp3lame', 'aac': 'aac', 'm4a': 'aac', 'opus': 'opus', 'vorbis': 'libvorbis', 'wav': None}[self._preferredcodec]
169             extension = self._preferredcodec
170             more_opts = []
171             if self._preferredquality is not None:
172                 if int(self._preferredquality) < 10:
173                     more_opts += [self._exes['avconv'] and '-q:a' or '-aq', self._preferredquality]
174                 else:
175                     more_opts += [self._exes['avconv'] and '-b:a' or '-ab', self._preferredquality + 'k']
176             if self._preferredcodec == 'aac':
177                 more_opts += ['-f', 'adts']
178             if self._preferredcodec == 'm4a':
179                 more_opts += [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc']
180             if self._preferredcodec == 'vorbis':
181                 extension = 'ogg'
182             if self._preferredcodec == 'wav':
183                 extension = 'wav'
184                 more_opts += ['-f', 'wav']
185
186         prefix, sep, ext = path.rpartition(u'.') # not os.path.splitext, since the latter does not work on unicode in all setups
187         new_path = prefix + sep + extension
188
189         # If we download foo.mp3 and convert it to... foo.mp3, then don't delete foo.mp3, silly.
190         if new_path == path:
191             self._nopostoverwrites = True
192
193         try:
194             if self._nopostoverwrites and os.path.exists(encodeFilename(new_path)):
195                 self._downloader.to_screen(u'[youtube] Post-process file %s exists, skipping' % new_path)
196             else:
197                 self._downloader.to_screen(u'[' + (self._exes['avconv'] and 'avconv' or 'ffmpeg') + '] Destination: ' + new_path)
198                 self.run_ffmpeg(path, new_path, acodec, more_opts)
199         except:
200             etype,e,tb = sys.exc_info()
201             if isinstance(e, AudioConversionError):
202                 msg = u'audio conversion failed: ' + e.message
203             else:
204                 msg = u'error running ' + (self._exes['avconv'] and 'avconv' or 'ffmpeg')
205             raise PostProcessingError(msg)
206
207         # Try to update the date time for extracted audio file.
208         if information.get('filetime') is not None:
209             try:
210                 os.utime(encodeFilename(new_path), (time.time(), information['filetime']))
211             except:
212                 self._downloader.report_warning(u'Cannot update utime of audio file')
213
214         information['filepath'] = new_path
215         return self._nopostoverwrites,information
216
217 class FFmpegVideoConvertor(FFmpegPostProcessor):
218     def __init__(self, downloader=None,preferedformat=None):
219         super(FFmpegVideoConvertor, self).__init__(downloader)
220         self._preferedformat=preferedformat
221
222     def run(self, information):
223         path = information['filepath']
224         prefix, sep, ext = path.rpartition(u'.')
225         outpath = prefix + sep + self._preferedformat
226         if information['ext'] == self._preferedformat:
227             self._downloader.to_screen(u'[ffmpeg] Not converting video file %s - already is in target format %s' % (path, self._preferedformat))
228             return True,information
229         self._downloader.to_screen(u'['+'ffmpeg'+'] Converting video from %s to %s, Destination: ' % (information['ext'], self._preferedformat) +outpath)
230         self.run_ffmpeg(path, outpath, [])
231         information['filepath'] = outpath
232         information['format'] = self._preferedformat
233         information['ext'] = self._preferedformat
234         return False,information