[subtitles] Added tests to check correct behavior when no subtitles are
[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']: return None
104         try:
105             cmd = [self._exes['avprobe'] or self._exes['ffprobe'], '-show_streams', encodeFilename(self._ffmpeg_filename_argument(path))]
106             handle = subprocess.Popen(cmd, stderr=compat_subprocess_get_DEVNULL(), stdout=subprocess.PIPE)
107             output = handle.communicate()[0]
108             if handle.wait() != 0:
109                 return None
110         except (IOError, OSError):
111             return None
112         audio_codec = None
113         for line in output.decode('ascii', 'ignore').split('\n'):
114             if line.startswith('codec_name='):
115                 audio_codec = line.split('=')[1].strip()
116             elif line.strip() == 'codec_type=audio' and audio_codec is not None:
117                 return audio_codec
118         return None
119
120     def run_ffmpeg(self, path, out_path, codec, more_opts):
121         if not self._exes['ffmpeg'] and not self._exes['avconv']:
122             raise AudioConversionError('ffmpeg or avconv not found. Please install one.')
123         if codec is None:
124             acodec_opts = []
125         else:
126             acodec_opts = ['-acodec', codec]
127         opts = ['-vn'] + acodec_opts + more_opts
128         try:
129             FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts)
130         except FFmpegPostProcessorError as err:
131             raise AudioConversionError(err.message)
132
133     def run(self, information):
134         path = information['filepath']
135
136         filecodec = self.get_audio_codec(path)
137         if filecodec is None:
138             raise PostProcessingError(u'WARNING: unable to obtain file audio codec with ffprobe')
139
140         more_opts = []
141         if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
142             if filecodec == 'aac' and self._preferredcodec in ['m4a', 'best']:
143                 # Lossless, but in another container
144                 acodec = 'copy'
145                 extension = 'm4a'
146                 more_opts = [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc']
147             elif filecodec in ['aac', 'mp3', 'vorbis', 'opus']:
148                 # Lossless if possible
149                 acodec = 'copy'
150                 extension = filecodec
151                 if filecodec == 'aac':
152                     more_opts = ['-f', 'adts']
153                 if filecodec == 'vorbis':
154                     extension = 'ogg'
155             else:
156                 # MP3 otherwise.
157                 acodec = 'libmp3lame'
158                 extension = 'mp3'
159                 more_opts = []
160                 if self._preferredquality is not None:
161                     if int(self._preferredquality) < 10:
162                         more_opts += [self._exes['avconv'] and '-q:a' or '-aq', self._preferredquality]
163                     else:
164                         more_opts += [self._exes['avconv'] and '-b:a' or '-ab', self._preferredquality + 'k']
165         else:
166             # We convert the audio (lossy)
167             acodec = {'mp3': 'libmp3lame', 'aac': 'aac', 'm4a': 'aac', 'opus': 'opus', 'vorbis': 'libvorbis', 'wav': None}[self._preferredcodec]
168             extension = self._preferredcodec
169             more_opts = []
170             if self._preferredquality is not None:
171                 if int(self._preferredquality) < 10:
172                     more_opts += [self._exes['avconv'] and '-q:a' or '-aq', self._preferredquality]
173                 else:
174                     more_opts += [self._exes['avconv'] and '-b:a' or '-ab', self._preferredquality + 'k']
175             if self._preferredcodec == 'aac':
176                 more_opts += ['-f', 'adts']
177             if self._preferredcodec == 'm4a':
178                 more_opts += [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc']
179             if self._preferredcodec == 'vorbis':
180                 extension = 'ogg'
181             if self._preferredcodec == 'wav':
182                 extension = 'wav'
183                 more_opts += ['-f', 'wav']
184
185         prefix, sep, ext = path.rpartition(u'.') # not os.path.splitext, since the latter does not work on unicode in all setups
186         new_path = prefix + sep + extension
187
188         # If we download foo.mp3 and convert it to... foo.mp3, then don't delete foo.mp3, silly.
189         if new_path == path:
190             self._nopostoverwrites = True
191
192         try:
193             if self._nopostoverwrites and os.path.exists(encodeFilename(new_path)):
194                 self._downloader.to_screen(u'[youtube] Post-process file %s exists, skipping' % new_path)
195             else:
196                 self._downloader.to_screen(u'[' + (self._exes['avconv'] and 'avconv' or 'ffmpeg') + '] Destination: ' + new_path)
197                 self.run_ffmpeg(path, new_path, acodec, more_opts)
198         except:
199             etype,e,tb = sys.exc_info()
200             if isinstance(e, AudioConversionError):
201                 msg = u'audio conversion failed: ' + e.message
202             else:
203                 msg = u'error running ' + (self._exes['avconv'] and 'avconv' or 'ffmpeg')
204             raise PostProcessingError(msg)
205
206         # Try to update the date time for extracted audio file.
207         if information.get('filetime') is not None:
208             try:
209                 os.utime(encodeFilename(new_path), (time.time(), information['filetime']))
210             except:
211                 self._downloader.to_stderr(u'WARNING: Cannot update utime of audio file')
212
213         information['filepath'] = new_path
214         return self._nopostoverwrites,information
215
216 class FFmpegVideoConvertor(FFmpegPostProcessor):
217     def __init__(self, downloader=None,preferedformat=None):
218         super(FFmpegVideoConvertor, self).__init__(downloader)
219         self._preferedformat=preferedformat
220
221     def run(self, information):
222         path = information['filepath']
223         prefix, sep, ext = path.rpartition(u'.')
224         outpath = prefix + sep + self._preferedformat
225         if information['ext'] == self._preferedformat:
226             self._downloader.to_screen(u'[ffmpeg] Not converting video file %s - already is in target format %s' % (path, self._preferedformat))
227             return True,information
228         self._downloader.to_screen(u'['+'ffmpeg'+'] Converting video from %s to %s, Destination: ' % (information['ext'], self._preferedformat) +outpath)
229         self.run_ffmpeg(path, outpath, [])
230         information['filepath'] = outpath
231         information['format'] = self._preferedformat
232         information['ext'] = self._preferedformat
233         return False,information