Merge pull request #386 from FiloSottile/blip
[youtube-dl] / youtube_dl / PostProcessor.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3
4 import os
5 import subprocess
6 import sys
7 import time
8
9 from utils import *
10
11
12 class PostProcessor(object):
13         """Post Processor class.
14
15         PostProcessor objects can be added to downloaders with their
16         add_post_processor() method. When the downloader has finished a
17         successful download, it will take its internal chain of PostProcessors
18         and start calling the run() method on each one of them, first with
19         an initial argument and then with the returned value of the previous
20         PostProcessor.
21
22         The chain will be stopped if one of them ever returns None or the end
23         of the chain is reached.
24
25         PostProcessor objects follow a "mutual registration" process similar
26         to InfoExtractor objects.
27         """
28
29         _downloader = None
30
31         def __init__(self, downloader=None):
32                 self._downloader = downloader
33
34         def set_downloader(self, downloader):
35                 """Sets the downloader for this PP."""
36                 self._downloader = downloader
37
38         def run(self, information):
39                 """Run the PostProcessor.
40
41                 The "information" argument is a dictionary like the ones
42                 composed by InfoExtractors. The only difference is that this
43                 one has an extra field called "filepath" that points to the
44                 downloaded file.
45
46                 When this method returns None, the postprocessing chain is
47                 stopped. However, this method may return an information
48                 dictionary that will be passed to the next postprocessing
49                 object in the chain. It can be the one it received after
50                 changing some fields.
51
52                 In addition, this method may raise a PostProcessingError
53                 exception that will be taken into account by the downloader
54                 it was called from.
55                 """
56                 return information # by default, do nothing
57
58 class AudioConversionError(BaseException):
59         def __init__(self, message):
60                 self.message = message
61
62 class FFmpegExtractAudioPP(PostProcessor):
63         def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, keepvideo=False):
64                 PostProcessor.__init__(self, downloader)
65                 if preferredcodec is None:
66                         preferredcodec = 'best'
67                 self._preferredcodec = preferredcodec
68                 self._preferredquality = preferredquality
69                 self._keepvideo = keepvideo
70                 self._exes = self.detect_executables()
71
72         @staticmethod
73         def detect_executables():
74                 available = {'avprobe' : False, 'avconv' : False, 'ffmpeg' : False, 'ffprobe' : False}
75                 for path in os.environ["PATH"].split(os.pathsep):
76                         for program in available.keys():
77                                 exe_file = os.path.join(path, program)
78                                 if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK):
79                                         available[program] = exe_file
80                 return available
81
82         def get_audio_codec(self, path):
83                 if not self._exes['ffprobe'] and not self._exes['avprobe']: return None
84                 try:
85                         cmd = [self._exes['avprobe'] or self._exes['ffprobe'], '-show_streams', '--', encodeFilename(path)]
86                         handle = subprocess.Popen(cmd, stderr=file(os.path.devnull, 'w'), stdout=subprocess.PIPE)
87                         output = handle.communicate()[0]
88                         if handle.wait() != 0:
89                                 return None
90                 except (IOError, OSError):
91                         return None
92                 audio_codec = None
93                 for line in output.split('\n'):
94                         if line.startswith('codec_name='):
95                                 audio_codec = line.split('=')[1].strip()
96                         elif line.strip() == 'codec_type=audio' and audio_codec is not None:
97                                 return audio_codec
98                 return None
99
100         def run_ffmpeg(self, path, out_path, codec, more_opts):
101                 if not self._exes['ffmpeg'] and not self._exes['avconv']:
102                         raise AudioConversionError('ffmpeg or avconv not found. Please install one.')   
103                 if codec is None:
104                         acodec_opts = []
105                 else:
106                         acodec_opts = ['-acodec', codec]
107                 cmd = ([self._exes['avconv'] or self._exes['ffmpeg'], '-y', '-i', encodeFilename(path), '-vn']
108                            + acodec_opts + more_opts +
109                            ['--', encodeFilename(out_path)])
110                 p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
111                 stdout,stderr = p.communicate()
112                 if p.returncode != 0:
113                         msg = stderr.strip().split('\n')[-1]
114                         raise AudioConversionError(msg)
115
116         def run(self, information):
117                 path = information['filepath']
118
119                 filecodec = self.get_audio_codec(path)
120                 if filecodec is None:
121                         self._downloader.to_stderr(u'WARNING: unable to obtain file audio codec with ffprobe')
122                         return None
123
124                 more_opts = []
125                 if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
126                         if self._preferredcodec == 'm4a' and filecodec == 'aac':
127                                 # Lossless, but in another container
128                                 acodec = 'copy'
129                                 extension = self._preferredcodec
130                                 more_opts = [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc']
131                         elif filecodec in ['aac', 'mp3', 'vorbis']:
132                                 # Lossless if possible
133                                 acodec = 'copy'
134                                 extension = filecodec
135                                 if filecodec == 'aac':
136                                         more_opts = ['-f', 'adts']
137                                 if filecodec == 'vorbis':
138                                         extension = 'ogg'
139                         else:
140                                 # MP3 otherwise.
141                                 acodec = 'libmp3lame'
142                                 extension = 'mp3'
143                                 more_opts = []
144                                 if self._preferredquality is not None:
145                                         more_opts += [self._exes['avconv'] and '-b:a' or '-ab', self._preferredquality]
146                 else:
147                         # We convert the audio (lossy)
148                         acodec = {'mp3': 'libmp3lame', 'aac': 'aac', 'm4a': 'aac', 'vorbis': 'libvorbis', 'wav': None}[self._preferredcodec]
149                         extension = self._preferredcodec
150                         more_opts = []
151                         if self._preferredquality is not None:
152                                 more_opts += [self._exes['avconv'] and '-b:a' or '-ab', self._preferredquality]
153                         if self._preferredcodec == 'aac':
154                                 more_opts += ['-f', 'adts']
155                         if self._preferredcodec == 'm4a':
156                                 more_opts += [self._exes['avconv'] and '-bsf:a' or '-absf', 'aac_adtstoasc']
157                         if self._preferredcodec == 'vorbis':
158                                 extension = 'ogg'
159                         if self._preferredcodec == 'wav':
160                                 extension = 'wav'
161                                 more_opts += ['-f', 'wav']
162
163                 prefix, sep, ext = path.rpartition(u'.') # not os.path.splitext, since the latter does not work on unicode in all setups
164                 new_path = prefix + sep + extension
165                 self._downloader.to_screen(u'[' + (self._exes['avconv'] and 'avconv' or 'ffmpeg') + '] Destination: ' + new_path)
166                 try:
167                         self.run_ffmpeg(path, new_path, acodec, more_opts)
168                 except:
169                         etype,e,tb = sys.exc_info()
170                         if isinstance(e, AudioConversionError):
171                                 self._downloader.to_stderr(u'ERROR: audio conversion failed: ' + e.message)
172                         else:
173                                 self._downloader.to_stderr(u'ERROR: error running ' + (self._exes['avconv'] and 'avconv' or 'ffmpeg'))
174                         return None
175
176                 # Try to update the date time for extracted audio file.
177                 if information.get('filetime') is not None:
178                         try:
179                                 os.utime(encodeFilename(new_path), (time.time(), information['filetime']))
180                         except:
181                                 self._downloader.to_stderr(u'WARNING: Cannot update utime of audio file')
182
183                 if not self._keepvideo:
184                         try:
185                                 os.remove(encodeFilename(path))
186                         except (IOError, OSError):
187                                 self._downloader.to_stderr(u'WARNING: Unable to remove downloaded video file')
188                                 return None
189
190                 information['filepath'] = new_path
191                 return information