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