]>
Commit | Line | Data |
---|---|---|
d77c3dfd FV |
1 | #!/usr/bin/env python |
2 | # -*- coding: utf-8 -*- | |
3 | ||
9e8056d5 PH |
4 | from __future__ import absolute_import |
5 | ||
d77c3dfd FV |
6 | import os |
7 | import subprocess | |
8 | import sys | |
9 | import time | |
10 | ||
9e8056d5 | 11 | from .utils import * |
d77c3dfd FV |
12 | |
13 | ||
14 | class PostProcessor(object): | |
59ae15a5 | 15 | """Post Processor class. |
d77c3dfd | 16 | |
59ae15a5 PH |
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. | |
d77c3dfd | 23 | |
59ae15a5 PH |
24 | The chain will be stopped if one of them ever returns None or the end |
25 | of the chain is reached. | |
d77c3dfd | 26 | |
59ae15a5 PH |
27 | PostProcessor objects follow a "mutual registration" process similar |
28 | to InfoExtractor objects. | |
29 | """ | |
d77c3dfd | 30 | |
59ae15a5 | 31 | _downloader = None |
d77c3dfd | 32 | |
59ae15a5 PH |
33 | def __init__(self, downloader=None): |
34 | self._downloader = downloader | |
d77c3dfd | 35 | |
59ae15a5 PH |
36 | def set_downloader(self, downloader): |
37 | """Sets the downloader for this PP.""" | |
38 | self._downloader = downloader | |
d77c3dfd | 39 | |
59ae15a5 PH |
40 | def run(self, information): |
41 | """Run the PostProcessor. | |
d77c3dfd | 42 | |
59ae15a5 PH |
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. | |
d77c3dfd | 47 | |
7851b379 PH |
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. | |
d77c3dfd | 51 | |
59ae15a5 | 52 | In addition, this method may raise a PostProcessingError |
7851b379 | 53 | exception if post processing fails. |
59ae15a5 | 54 | """ |
7851b379 | 55 | return None, information # by default, keep file and do nothing |
d77c3dfd | 56 | |
7851b379 PH |
57 | class FFmpegPostProcessorError(PostProcessingError): |
58 | pass | |
67d0c25e | 59 | |
7851b379 PH |
60 | class AudioConversionError(PostProcessingError): |
61 | pass | |
d77c3dfd | 62 | |
67d0c25e JMF |
63 | class FFmpegPostProcessor(PostProcessor): |
64 | def __init__(self,downloader=None): | |
59ae15a5 | 65 | PostProcessor.__init__(self, downloader) |
59ae15a5 PH |
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 | ||
67d0c25e JMF |
79 | def run_ffmpeg(self, path, out_path, opts): |
80 | if not self._exes['ffmpeg'] and not self._exes['avconv']: | |
7851b379 | 81 | raise FFmpegPostProcessorError(u'ffmpeg or avconv not found. Please install one.') |
67d0c25e JMF |
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 | msg = stderr.strip().split('\n')[-1] | |
7851b379 | 89 | raise FFmpegPostProcessorError(msg.decode('utf-8', 'replace')) |
67d0c25e JMF |
90 | |
91 | def _ffmpeg_filename_argument(self, fn): | |
92 | # ffmpeg broke --, see https://ffmpeg.org/trac/ffmpeg/ticket/2127 for details | |
93 | if fn.startswith(u'-'): | |
94 | return u'./' + fn | |
95 | return fn | |
96 | ||
97 | class FFmpegExtractAudioPP(FFmpegPostProcessor): | |
7851b379 | 98 | def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False): |
67d0c25e JMF |
99 | FFmpegPostProcessor.__init__(self, downloader) |
100 | if preferredcodec is None: | |
101 | preferredcodec = 'best' | |
102 | self._preferredcodec = preferredcodec | |
103 | self._preferredquality = preferredquality | |
67d0c25e JMF |
104 | self._nopostoverwrites = nopostoverwrites |
105 | ||
59ae15a5 PH |
106 | def get_audio_codec(self, path): |
107 | if not self._exes['ffprobe'] and not self._exes['avprobe']: return None | |
108 | try: | |
712e86b9 | 109 | cmd = [self._exes['avprobe'] or self._exes['ffprobe'], '-show_streams', encodeFilename(self._ffmpeg_filename_argument(path))] |
5910e210 | 110 | handle = subprocess.Popen(cmd, stderr=compat_subprocess_get_DEVNULL(), stdout=subprocess.PIPE) |
59ae15a5 PH |
111 | output = handle.communicate()[0] |
112 | if handle.wait() != 0: | |
113 | return None | |
114 | except (IOError, OSError): | |
115 | return None | |
116 | audio_codec = None | |
5910e210 | 117 | for line in output.decode('ascii', 'ignore').split('\n'): |
59ae15a5 PH |
118 | if line.startswith('codec_name='): |
119 | audio_codec = line.split('=')[1].strip() | |
120 | elif line.strip() == 'codec_type=audio' and audio_codec is not None: | |
121 | return audio_codec | |
122 | return None | |
123 | ||
124 | def run_ffmpeg(self, path, out_path, codec, more_opts): | |
125 | if not self._exes['ffmpeg'] and not self._exes['avconv']: | |
0c007432 | 126 | raise AudioConversionError('ffmpeg or avconv not found. Please install one.') |
59ae15a5 PH |
127 | if codec is None: |
128 | acodec_opts = [] | |
129 | else: | |
130 | acodec_opts = ['-acodec', codec] | |
67d0c25e JMF |
131 | opts = ['-vn'] + acodec_opts + more_opts |
132 | try: | |
133 | FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts) | |
134 | except FFmpegPostProcessorError as err: | |
135 | raise AudioConversionError(err.message) | |
59ae15a5 PH |
136 | |
137 | def run(self, information): | |
138 | path = information['filepath'] | |
139 | ||
140 | filecodec = self.get_audio_codec(path) | |
141 | if filecodec is None: | |
7851b379 | 142 | raise PostProcessingError(u'WARNING: unable to obtain file audio codec with ffprobe') |
59ae15a5 PH |
143 | |
144 | more_opts = [] | |
145 | if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'): | |
146 | if self._preferredcodec == 'm4a' and filecodec == 'aac': | |
147 | # Lossless, but in another container | |
148 | acodec = 'copy' | |
149 | extension = self._preferredcodec | |
150 | more_opts = [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc'] | |
510e6f6d | 151 | elif filecodec in ['aac', 'mp3', 'vorbis', 'opus']: |
59ae15a5 PH |
152 | # Lossless if possible |
153 | acodec = 'copy' | |
154 | extension = filecodec | |
155 | if filecodec == 'aac': | |
156 | more_opts = ['-f', 'adts'] | |
157 | if filecodec == 'vorbis': | |
158 | extension = 'ogg' | |
159 | else: | |
160 | # MP3 otherwise. | |
161 | acodec = 'libmp3lame' | |
162 | extension = 'mp3' | |
163 | more_opts = [] | |
164 | if self._preferredquality is not None: | |
165 | if int(self._preferredquality) < 10: | |
166 | more_opts += [self._exes['avconv'] and '-q:a' or '-aq', self._preferredquality] | |
167 | else: | |
168 | more_opts += [self._exes['avconv'] and '-b:a' or '-ab', self._preferredquality + 'k'] | |
169 | else: | |
170 | # We convert the audio (lossy) | |
510e6f6d | 171 | acodec = {'mp3': 'libmp3lame', 'aac': 'aac', 'm4a': 'aac', 'opus': 'opus', 'vorbis': 'libvorbis', 'wav': None}[self._preferredcodec] |
59ae15a5 PH |
172 | extension = self._preferredcodec |
173 | more_opts = [] | |
174 | if self._preferredquality is not None: | |
175 | if int(self._preferredquality) < 10: | |
176 | more_opts += [self._exes['avconv'] and '-q:a' or '-aq', self._preferredquality] | |
177 | else: | |
178 | more_opts += [self._exes['avconv'] and '-b:a' or '-ab', self._preferredquality + 'k'] | |
179 | if self._preferredcodec == 'aac': | |
180 | more_opts += ['-f', 'adts'] | |
181 | if self._preferredcodec == 'm4a': | |
182 | more_opts += [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc'] | |
183 | if self._preferredcodec == 'vorbis': | |
184 | extension = 'ogg' | |
185 | if self._preferredcodec == 'wav': | |
186 | extension = 'wav' | |
187 | more_opts += ['-f', 'wav'] | |
188 | ||
189 | prefix, sep, ext = path.rpartition(u'.') # not os.path.splitext, since the latter does not work on unicode in all setups | |
190 | new_path = prefix + sep + extension | |
59ae15a5 | 191 | try: |
b7298b6e BPG |
192 | if self._nopostoverwrites and os.path.exists(encodeFilename(new_path)): |
193 | self._downloader.to_screen(u'[youtube] Post-process file %s exists, skipping' % new_path) | |
194 | else: | |
195 | self._downloader.to_screen(u'[' + (self._exes['avconv'] and 'avconv' or 'ffmpeg') + '] Destination: ' + new_path) | |
196 | self.run_ffmpeg(path, new_path, acodec, more_opts) | |
59ae15a5 PH |
197 | except: |
198 | etype,e,tb = sys.exc_info() | |
199 | if isinstance(e, AudioConversionError): | |
7851b379 | 200 | msg = u'audio conversion failed: ' + e.message |
59ae15a5 | 201 | else: |
7851b379 PH |
202 | msg = u'error running ' + (self._exes['avconv'] and 'avconv' or 'ffmpeg') |
203 | raise PostProcessingError(msg) | |
59ae15a5 PH |
204 | |
205 | # Try to update the date time for extracted audio file. | |
206 | if information.get('filetime') is not None: | |
207 | try: | |
208 | os.utime(encodeFilename(new_path), (time.time(), information['filetime'])) | |
209 | except: | |
210 | self._downloader.to_stderr(u'WARNING: Cannot update utime of audio file') | |
211 | ||
59ae15a5 | 212 | information['filepath'] = new_path |
7851b379 | 213 | return False,information |
712e86b9 | 214 | |
67d0c25e JMF |
215 | class FFmpegVideoConvertor(FFmpegPostProcessor): |
216 | def __init__(self, downloader=None,preferedformat=None): | |
7851b379 | 217 | super(FFmpegVideoConvertor, self).__init__(downloader) |
67d0c25e | 218 | self._preferedformat=preferedformat |
712e86b9 | 219 | |
67d0c25e JMF |
220 | def run(self, information): |
221 | path = information['filepath'] | |
222 | prefix, sep, ext = path.rpartition(u'.') | |
223 | outpath = prefix + sep + self._preferedformat | |
7851b379 PH |
224 | if information['ext'] == self._preferedformat: |
225 | self._downloader.to_screen(u'[ffmpeg] Not converting video file %s - already is in target format %s' % (path, self._preferedformat)) | |
226 | return True,information | |
227 | self._downloader.to_screen(u'['+'ffmpeg'+'] Converting video from %s to %s, Destination: ' % (information['ext'], self._preferedformat) +outpath) | |
67d0c25e JMF |
228 | self.run_ffmpeg(path, outpath, []) |
229 | information['filepath'] = outpath | |
230 | information['format'] = self._preferedformat | |
7851b379 PH |
231 | information['ext'] = self._preferedformat |
232 | return False,information |