]>
Commit | Line | Data |
---|---|---|
dcdb292f | 1 | # coding: utf-8 |
ddbed364 | 2 | from __future__ import unicode_literals |
3 | ||
95131b21 | 4 | import base64 |
5 | import imghdr | |
ddbed364 | 6 | import os |
95131b21 | 7 | import subprocess |
06167fbb | 8 | import re |
06167fbb | 9 | |
10 | try: | |
95131b21 | 11 | from mutagen.flac import Picture, FLAC |
acdecdfa | 12 | from mutagen.mp4 import MP4, MP4Cover |
13 | from mutagen.oggopus import OggOpus | |
14 | from mutagen.oggvorbis import OggVorbis | |
06ff212d | 15 | has_mutagen = True |
06167fbb | 16 | except ImportError: |
06ff212d | 17 | has_mutagen = False |
ddbed364 | 18 | |
8326b00a | 19 | from .common import PostProcessor |
8fa43c73 | 20 | from .ffmpeg import ( |
21 | FFmpegPostProcessor, | |
22 | FFmpegThumbnailsConvertorPP, | |
23 | ) | |
ddbed364 | 24 | from ..utils import ( |
25 | check_executable, | |
2cc6d135 | 26 | encodeArgument, |
ddbed364 | 27 | encodeFilename, |
06167fbb | 28 | error_to_compat_str, |
d3c93ec2 | 29 | Popen, |
ddbed364 | 30 | PostProcessingError, |
31 | prepend_extension, | |
f5b1bca9 | 32 | shell_quote, |
ddbed364 | 33 | ) |
34 | ||
35 | ||
36 | class EmbedThumbnailPPError(PostProcessingError): | |
37 | pass | |
38 | ||
39 | ||
31fd9c76 | 40 | class EmbedThumbnailPP(FFmpegPostProcessor): |
1b77b347 | 41 | |
8e595397 | 42 | def __init__(self, downloader=None, already_have_thumbnail=False): |
8fa43c73 | 43 | FFmpegPostProcessor.__init__(self, downloader) |
8e595397 YCH |
44 | self._already_have_thumbnail = already_have_thumbnail |
45 | ||
95131b21 | 46 | def _get_thumbnail_resolution(self, filename, thumbnail_dict): |
47 | def guess(): | |
48 | width, height = thumbnail_dict.get('width'), thumbnail_dict.get('height') | |
49 | if width and height: | |
50 | return width, height | |
51 | ||
52 | try: | |
53 | size_regex = r',\s*(?P<w>\d+)x(?P<h>\d+)\s*[,\[]' | |
00034c14 | 54 | size_result = self.run_ffmpeg(filename, None, ['-hide_banner'], expected_retcodes=(1,)) |
95131b21 | 55 | mobj = re.search(size_regex, size_result) |
56 | if mobj is None: | |
57 | return guess() | |
58 | except PostProcessingError as err: | |
59 | self.report_warning('unable to find the thumbnail resolution; %s' % error_to_compat_str(err)) | |
60 | return guess() | |
61 | return int(mobj.group('w')), int(mobj.group('h')) | |
62 | ||
acdecdfa | 63 | def _report_run(self, exe, filename): |
64 | self.to_screen('%s: Adding thumbnail to "%s"' % (exe, filename)) | |
65 | ||
8326b00a | 66 | @PostProcessor._restrict_to(images=False) |
ddbed364 | 67 | def run(self, info): |
68 | filename = info['filepath'] | |
69 | temp_filename = prepend_extension(filename, 'temp') | |
ddbed364 | 70 | |
8e595397 | 71 | if not info.get('thumbnails'): |
1b77b347 | 72 | self.to_screen('There aren\'t any thumbnails to embed') |
b5cbe3d6 | 73 | return [], info |
ddbed364 | 74 | |
337e0c62 | 75 | idx = next((-i for i, t in enumerate(info['thumbnails'][::-1], 1) if t.get('filepath')), None) |
885cc0b7 | 76 | if idx is None: |
77 | self.to_screen('There are no thumbnails on disk') | |
78 | return [], info | |
79 | thumbnail_filename = info['thumbnails'][idx]['filepath'] | |
c33a8639 | 80 | if not os.path.exists(encodeFilename(thumbnail_filename)): |
f446cc66 | 81 | self.report_warning('Skipping embedding the thumbnail because the file is missing.') |
c33a8639 YCH |
82 | return [], info |
83 | ||
bff857a8 | 84 | # Correct extension for WebP file with wrong extension (see #25687, #25717) |
8fa43c73 | 85 | convertor = FFmpegThumbnailsConvertorPP(self._downloader) |
885cc0b7 | 86 | convertor.fixup_webp(info, idx) |
8fa43c73 | 87 | |
885cc0b7 | 88 | original_thumbnail = thumbnail_filename = info['thumbnails'][idx]['filepath'] |
bff857a8 | 89 | |
a927acb1 | 90 | # Convert unsupported thumbnail formats to PNG (see #25687, #25717) |
91 | # Original behavior was to convert to JPG, but since JPG is a lossy | |
92 | # format, there will be some additional data loss. | |
93 | # PNG, on the other hand, is lossless. | |
4d224a30 | 94 | thumbnail_ext = os.path.splitext(thumbnail_filename)[1][1:] |
15a4fd53 | 95 | if thumbnail_ext not in ('jpg', 'jpeg', 'png'): |
a927acb1 | 96 | thumbnail_filename = convertor.convert_thumbnail(thumbnail_filename, 'png') |
97 | thumbnail_ext = 'png' | |
777d5a45 | 98 | |
ca879745 | 99 | mtime = os.stat(encodeFilename(filename)).st_mtime |
100 | ||
67002a5a | 101 | success = True |
8e2915d7 | 102 | if info['ext'] == 'mp3': |
92995e62 | 103 | options = [ |
c76eb41b | 104 | '-c', 'copy', '-map', '0:0', '-map', '1:0', '-id3v2_version', '3', |
e51f368c | 105 | '-metadata:s:v', 'title="Album cover"', '-metadata:s:v', 'comment="Cover (front)"'] |
ddbed364 | 106 | |
acdecdfa | 107 | self._report_run('ffmpeg', filename) |
bb8ca1d1 | 108 | self.run_ffmpeg_multiple_files([filename, thumbnail_filename], temp_filename, options) |
ddbed364 | 109 | |
06167fbb | 110 | elif info['ext'] in ['mkv', 'mka']: |
397235c5 | 111 | options = list(self.stream_copy_opts()) |
ddbed364 | 112 | |
06167fbb | 113 | mimetype = 'image/%s' % ('png' if thumbnail_ext == 'png' else 'jpeg') |
114 | old_stream, new_stream = self.get_stream_number( | |
115 | filename, ('tags', 'mimetype'), mimetype) | |
116 | if old_stream is not None: | |
117 | options.extend(['-map', '-0:%d' % old_stream]) | |
118 | new_stream -= 1 | |
119 | options.extend([ | |
120 | '-attach', thumbnail_filename, | |
121 | '-metadata:s:%d' % new_stream, 'mimetype=%s' % mimetype, | |
122 | '-metadata:s:%d' % new_stream, 'filename=cover.%s' % thumbnail_ext]) | |
ddbed364 | 123 | |
acdecdfa | 124 | self._report_run('ffmpeg', filename) |
06167fbb | 125 | self.run_ffmpeg(filename, temp_filename, options) |
126 | ||
127 | elif info['ext'] in ['m4a', 'mp4', 'mov']: | |
e858a9d6 | 128 | prefer_atomicparsley = 'embed-thumbnail-atomicparsley' in self.get_param('compat_opts', []) |
acdecdfa | 129 | # Method 1: Use mutagen |
e858a9d6 | 130 | if not has_mutagen or prefer_atomicparsley: |
acdecdfa | 131 | success = False |
132 | else: | |
133 | try: | |
134 | self._report_run('mutagen', filename) | |
135 | meta = MP4(filename) | |
136 | # NOTE: the 'covr' atom is a non-standard MPEG-4 atom, | |
137 | # Apple iTunes 'M4A' files include the 'moov.udta.meta.ilst' atom. | |
f17c7022 | 138 | f = {'jpeg': MP4Cover.FORMAT_JPEG, 'png': MP4Cover.FORMAT_PNG}[imghdr.what(thumbnail_filename)] |
acdecdfa | 139 | with open(thumbnail_filename, 'rb') as thumbfile: |
140 | thumb_data = thumbfile.read() | |
141 | meta.tags['covr'] = [MP4Cover(data=thumb_data, imageformat=f)] | |
142 | meta.save() | |
143 | temp_filename = filename | |
144 | except Exception as err: | |
145 | self.report_warning('unable to embed using mutagen; %s' % error_to_compat_str(err)) | |
146 | success = False | |
06167fbb | 147 | |
77cee0f1 | 148 | # Method 2: Use AtomicParsley |
149 | if not success: | |
150 | success = True | |
151 | atomicparsley = next(( | |
152 | x for x in ['AtomicParsley', 'atomicparsley'] | |
153 | if check_executable(x, ['-v'])), None) | |
154 | if atomicparsley is None: | |
155 | self.to_screen('Neither mutagen nor AtomicParsley was found. Falling back to ffmpeg') | |
156 | success = False | |
157 | else: | |
158 | if not prefer_atomicparsley: | |
159 | self.to_screen('mutagen was not found. Falling back to AtomicParsley') | |
160 | cmd = [encodeFilename(atomicparsley, True), | |
161 | encodeFilename(filename, True), | |
162 | encodeArgument('--artwork'), | |
163 | encodeFilename(thumbnail_filename, True), | |
164 | encodeArgument('-o'), | |
165 | encodeFilename(temp_filename, True)] | |
166 | cmd += [encodeArgument(o) for o in self._configuration_args('AtomicParsley')] | |
167 | ||
168 | self._report_run('atomicparsley', filename) | |
169 | self.write_debug('AtomicParsley command line: %s' % shell_quote(cmd)) | |
170 | p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
171 | stdout, stderr = p.communicate_or_kill() | |
172 | if p.returncode != 0: | |
173 | msg = stderr.decode('utf-8', 'replace').strip() | |
174 | self.report_warning(f'Unable to embed thumbnails using AtomicParsley; {msg}') | |
175 | # for formats that don't support thumbnails (like 3gp) AtomicParsley | |
176 | # won't create to the temporary file | |
177 | if b'No changes' in stdout: | |
178 | self.report_warning('The file format doesn\'t support embedding a thumbnail') | |
179 | success = False | |
180 | ||
181 | # Method 3: Use ffmpeg+ffprobe | |
182 | # Thumbnails attached using this method doesn't show up as cover in some cases | |
183 | # See https://github.com/yt-dlp/yt-dlp/issues/2125, https://github.com/yt-dlp/yt-dlp/issues/411 | |
184 | if not success: | |
acdecdfa | 185 | success = True |
186 | try: | |
397235c5 | 187 | options = [*self.stream_copy_opts(), '-map', '1'] |
acdecdfa | 188 | |
189 | old_stream, new_stream = self.get_stream_number( | |
190 | filename, ('disposition', 'attached_pic'), 1) | |
191 | if old_stream is not None: | |
192 | options.extend(['-map', '-0:%d' % old_stream]) | |
193 | new_stream -= 1 | |
194 | options.extend(['-disposition:%s' % new_stream, 'attached_pic']) | |
195 | ||
196 | self._report_run('ffmpeg', filename) | |
197 | self.run_ffmpeg_multiple_files([filename, thumbnail_filename], temp_filename, options) | |
198 | except PostProcessingError as err: | |
06167fbb | 199 | success = False |
77cee0f1 | 200 | raise EmbedThumbnailPPError(f'Unable to embed using ffprobe & ffmpeg; {err}') |
06167fbb | 201 | |
95131b21 | 202 | elif info['ext'] in ['ogg', 'opus', 'flac']: |
06ff212d | 203 | if not has_mutagen: |
e38df8f9 | 204 | raise EmbedThumbnailPPError('module mutagen was not found. Please install using `python -m pip install mutagen`') |
06167fbb | 205 | |
acdecdfa | 206 | self._report_run('mutagen', filename) |
95131b21 | 207 | f = {'opus': OggOpus, 'flac': FLAC, 'ogg': OggVorbis}[info['ext']](filename) |
208 | ||
209 | pic = Picture() | |
210 | pic.mime = 'image/%s' % imghdr.what(thumbnail_filename) | |
211 | with open(thumbnail_filename, 'rb') as thumbfile: | |
212 | pic.data = thumbfile.read() | |
213 | pic.type = 3 # front cover | |
885cc0b7 | 214 | res = self._get_thumbnail_resolution(thumbnail_filename, info['thumbnails'][idx]) |
95131b21 | 215 | if res is not None: |
216 | pic.width, pic.height = res | |
217 | ||
218 | if info['ext'] == 'flac': | |
219 | f.add_picture(pic) | |
220 | else: | |
221 | # https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE | |
222 | f['METADATA_BLOCK_PICTURE'] = base64.b64encode(pic.write()).decode('ascii') | |
06167fbb | 223 | f.save() |
acdecdfa | 224 | temp_filename = filename |
67002a5a | 225 | |
ddbed364 | 226 | else: |
95131b21 | 227 | raise EmbedThumbnailPPError('Supported filetypes for thumbnail embedding are: mp3, mkv/mka, ogg/opus/flac, m4a/mp4/mov') |
ddbed364 | 228 | |
06167fbb | 229 | if success and temp_filename != filename: |
d75201a8 | 230 | os.replace(temp_filename, filename) |
2e339f59 | 231 | |
ca879745 | 232 | self.try_utime(filename, mtime, mtime) |
233 | ||
2e339f59 | 234 | files_to_delete = [thumbnail_filename] |
de6000d9 | 235 | if self._already_have_thumbnail: |
2e339f59 | 236 | if original_thumbnail == thumbnail_filename: |
237 | files_to_delete = [] | |
0e004051 | 238 | elif original_thumbnail != thumbnail_filename: |
239 | files_to_delete.append(original_thumbnail) | |
67002a5a | 240 | return files_to_delete, info |