Dont delete source file when source file and post-processed file are the same
[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             msg = stderr.strip().split('\n')[-1]
89             raise FFmpegPostProcessorError(msg.decode('utf-8', 'replace'))
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):
98     def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False):
99         FFmpegPostProcessor.__init__(self, downloader)
100         if preferredcodec is None:
101             preferredcodec = 'best'
102         self._preferredcodec = preferredcodec
103         self._preferredquality = preferredquality
104         self._nopostoverwrites = nopostoverwrites
105
106     def get_audio_codec(self, path):
107         if not self._exes['ffprobe'] and not self._exes['avprobe']: return None
108         try:
109             cmd = [self._exes['avprobe'] or self._exes['ffprobe'], '-show_streams', encodeFilename(self._ffmpeg_filename_argument(path))]
110             handle = subprocess.Popen(cmd, stderr=compat_subprocess_get_DEVNULL(), stdout=subprocess.PIPE)
111             output = handle.communicate()[0]
112             if handle.wait() != 0:
113                 return None
114         except (IOError, OSError):
115             return None
116         audio_codec = None
117         for line in output.decode('ascii', 'ignore').split('\n'):
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']:
126             raise AudioConversionError('ffmpeg or avconv not found. Please install one.')
127         if codec is None:
128             acodec_opts = []
129         else:
130             acodec_opts = ['-acodec', codec]
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)
136
137     def run(self, information):
138         path = information['filepath']
139
140         filecodec = self.get_audio_codec(path)
141         if filecodec is None:
142             raise PostProcessingError(u'WARNING: unable to obtain file audio codec with ffprobe')
143
144         more_opts = []
145         if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
146             if filecodec == 'aac' and self._preferredcodec in ['m4a', 'best']:
147                 # Lossless, but in another container
148                 acodec = 'copy'
149                 extension = 'm4a'
150                 more_opts = [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc']
151             elif filecodec in ['aac', 'mp3', 'vorbis', 'opus']:
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)
171             acodec = {'mp3': 'libmp3lame', 'aac': 'aac', 'm4a': 'aac', 'opus': 'opus', 'vorbis': 'libvorbis', 'wav': None}[self._preferredcodec]
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
191
192         # If we download foo.mp3 and convert it to... foo.mp3, then don't delete foo.mp3, silly.
193         if new_path == path:
194             self._nopostoverwrites = True
195
196         try:
197             if self._nopostoverwrites and os.path.exists(encodeFilename(new_path)):
198                 self._downloader.to_screen(u'[youtube] Post-process file %s exists, skipping' % new_path)
199             else:
200                 self._downloader.to_screen(u'[' + (self._exes['avconv'] and 'avconv' or 'ffmpeg') + '] Destination: ' + new_path)
201                 self.run_ffmpeg(path, new_path, acodec, more_opts)
202         except:
203             etype,e,tb = sys.exc_info()
204             if isinstance(e, AudioConversionError):
205                 msg = u'audio conversion failed: ' + e.message
206             else:
207                 msg = u'error running ' + (self._exes['avconv'] and 'avconv' or 'ffmpeg')
208             raise PostProcessingError(msg)
209
210         # Try to update the date time for extracted audio file.
211         if information.get('filetime') is not None:
212             try:
213                 os.utime(encodeFilename(new_path), (time.time(), information['filetime']))
214             except:
215                 self._downloader.to_stderr(u'WARNING: Cannot update utime of audio file')
216
217         information['filepath'] = new_path
218         return self._nopostoverwrites,information
219
220 class FFmpegVideoConvertor(FFmpegPostProcessor):
221     def __init__(self, downloader=None,preferedformat=None):
222         super(FFmpegVideoConvertor, self).__init__(downloader)
223         self._preferedformat=preferedformat
224
225     def run(self, information):
226         path = information['filepath']
227         prefix, sep, ext = path.rpartition(u'.')
228         outpath = prefix + sep + self._preferedformat
229         if information['ext'] == self._preferedformat:
230             self._downloader.to_screen(u'[ffmpeg] Not converting video file %s - already is in target format %s' % (path, self._preferedformat))
231             return True,information
232         self._downloader.to_screen(u'['+'ffmpeg'+'] Converting video from %s to %s, Destination: ' % (information['ext'], self._preferedformat) +outpath)
233         self.run_ffmpeg(path, outpath, [])
234         information['filepath'] = outpath
235         information['format'] = self._preferedformat
236         information['ext'] = self._preferedformat
237         return False,information