embedthumbnail.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import base64
  2. import os
  3. import re
  4. import subprocess
  5. from .common import PostProcessor
  6. from .ffmpeg import FFmpegPostProcessor, FFmpegThumbnailsConvertorPP
  7. from ..compat import imghdr
  8. from ..dependencies import mutagen
  9. from ..utils import (
  10. Popen,
  11. PostProcessingError,
  12. check_executable,
  13. encodeArgument,
  14. encodeFilename,
  15. error_to_compat_str,
  16. prepend_extension,
  17. shell_quote,
  18. )
  19. if mutagen:
  20. from mutagen.flac import FLAC, Picture
  21. from mutagen.mp4 import MP4, MP4Cover
  22. from mutagen.oggopus import OggOpus
  23. from mutagen.oggvorbis import OggVorbis
  24. class EmbedThumbnailPPError(PostProcessingError):
  25. pass
  26. class EmbedThumbnailPP(FFmpegPostProcessor):
  27. def __init__(self, downloader=None, already_have_thumbnail=False):
  28. FFmpegPostProcessor.__init__(self, downloader)
  29. self._already_have_thumbnail = already_have_thumbnail
  30. def _get_thumbnail_resolution(self, filename, thumbnail_dict):
  31. def guess():
  32. width, height = thumbnail_dict.get('width'), thumbnail_dict.get('height')
  33. if width and height:
  34. return width, height
  35. try:
  36. size_regex = r',\s*(?P<w>\d+)x(?P<h>\d+)\s*[,\[]'
  37. size_result = self.run_ffmpeg(filename, None, ['-hide_banner'], expected_retcodes=(1,))
  38. mobj = re.search(size_regex, size_result)
  39. if mobj is None:
  40. return guess()
  41. except PostProcessingError as err:
  42. self.report_warning('unable to find the thumbnail resolution; %s' % error_to_compat_str(err))
  43. return guess()
  44. return int(mobj.group('w')), int(mobj.group('h'))
  45. def _report_run(self, exe, filename):
  46. self.to_screen(f'{exe}: Adding thumbnail to "{filename}"')
  47. @PostProcessor._restrict_to(images=False)
  48. def run(self, info):
  49. filename = info['filepath']
  50. temp_filename = prepend_extension(filename, 'temp')
  51. if not info.get('thumbnails'):
  52. self.to_screen('There aren\'t any thumbnails to embed')
  53. return [], info
  54. idx = next((-i for i, t in enumerate(info['thumbnails'][::-1], 1) if t.get('filepath')), None)
  55. if idx is None:
  56. self.to_screen('There are no thumbnails on disk')
  57. return [], info
  58. thumbnail_filename = info['thumbnails'][idx]['filepath']
  59. if not os.path.exists(encodeFilename(thumbnail_filename)):
  60. self.report_warning('Skipping embedding the thumbnail because the file is missing.')
  61. return [], info
  62. # Correct extension for WebP file with wrong extension (see #25687, #25717)
  63. convertor = FFmpegThumbnailsConvertorPP(self._downloader)
  64. convertor.fixup_webp(info, idx)
  65. original_thumbnail = thumbnail_filename = info['thumbnails'][idx]['filepath']
  66. # Convert unsupported thumbnail formats (see #25687, #25717)
  67. # PNG is preferred since JPEG is lossy
  68. thumbnail_ext = os.path.splitext(thumbnail_filename)[1][1:]
  69. if info['ext'] not in ('mkv', 'mka') and thumbnail_ext not in ('jpg', 'jpeg', 'png'):
  70. thumbnail_filename = convertor.convert_thumbnail(thumbnail_filename, 'png')
  71. thumbnail_ext = 'png'
  72. mtime = os.stat(encodeFilename(filename)).st_mtime
  73. success = True
  74. if info['ext'] == 'mp3':
  75. options = [
  76. '-c', 'copy', '-map', '0:0', '-map', '1:0', '-write_id3v1', '1', '-id3v2_version', '3',
  77. '-metadata:s:v', 'title="Album cover"', '-metadata:s:v', 'comment="Cover (front)"']
  78. self._report_run('ffmpeg', filename)
  79. self.run_ffmpeg_multiple_files([filename, thumbnail_filename], temp_filename, options)
  80. elif info['ext'] in ['mkv', 'mka']:
  81. options = list(self.stream_copy_opts())
  82. mimetype = f'image/{thumbnail_ext.replace("jpg", "jpeg")}'
  83. old_stream, new_stream = self.get_stream_number(
  84. filename, ('tags', 'mimetype'), mimetype)
  85. if old_stream is not None:
  86. options.extend(['-map', '-0:%d' % old_stream])
  87. new_stream -= 1
  88. options.extend([
  89. '-attach', thumbnail_filename,
  90. '-metadata:s:%d' % new_stream, 'mimetype=%s' % mimetype,
  91. '-metadata:s:%d' % new_stream, 'filename=cover.%s' % thumbnail_ext])
  92. self._report_run('ffmpeg', filename)
  93. self.run_ffmpeg(filename, temp_filename, options)
  94. elif info['ext'] in ['m4a', 'mp4', 'mov']:
  95. prefer_atomicparsley = 'embed-thumbnail-atomicparsley' in self.get_param('compat_opts', [])
  96. # Method 1: Use mutagen
  97. if not mutagen or prefer_atomicparsley:
  98. success = False
  99. else:
  100. try:
  101. self._report_run('mutagen', filename)
  102. meta = MP4(filename)
  103. # NOTE: the 'covr' atom is a non-standard MPEG-4 atom,
  104. # Apple iTunes 'M4A' files include the 'moov.udta.meta.ilst' atom.
  105. f = {'jpeg': MP4Cover.FORMAT_JPEG, 'png': MP4Cover.FORMAT_PNG}[imghdr.what(thumbnail_filename)]
  106. with open(thumbnail_filename, 'rb') as thumbfile:
  107. thumb_data = thumbfile.read()
  108. meta.tags['covr'] = [MP4Cover(data=thumb_data, imageformat=f)]
  109. meta.save()
  110. temp_filename = filename
  111. except Exception as err:
  112. self.report_warning('unable to embed using mutagen; %s' % error_to_compat_str(err))
  113. success = False
  114. # Method 2: Use AtomicParsley
  115. if not success:
  116. success = True
  117. atomicparsley = next((
  118. # libatomicparsley.so : See https://github.com/xibr/ytdlp-lazy/issues/1
  119. x for x in ['AtomicParsley', 'atomicparsley', 'libatomicparsley.so']
  120. if check_executable(x, ['-v'])), None)
  121. if atomicparsley is None:
  122. self.to_screen('Neither mutagen nor AtomicParsley was found. Falling back to ffmpeg')
  123. success = False
  124. else:
  125. if not prefer_atomicparsley:
  126. self.to_screen('mutagen was not found. Falling back to AtomicParsley')
  127. cmd = [encodeFilename(atomicparsley, True),
  128. encodeFilename(filename, True),
  129. encodeArgument('--artwork'),
  130. encodeFilename(thumbnail_filename, True),
  131. encodeArgument('-o'),
  132. encodeFilename(temp_filename, True)]
  133. cmd += [encodeArgument(o) for o in self._configuration_args('AtomicParsley')]
  134. self._report_run('atomicparsley', filename)
  135. self.write_debug('AtomicParsley command line: %s' % shell_quote(cmd))
  136. stdout, stderr, returncode = Popen.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  137. if returncode:
  138. self.report_warning(f'Unable to embed thumbnails using AtomicParsley; {stderr.strip()}')
  139. # for formats that don't support thumbnails (like 3gp) AtomicParsley
  140. # won't create to the temporary file
  141. if 'No changes' in stdout:
  142. self.report_warning('The file format doesn\'t support embedding a thumbnail')
  143. success = False
  144. # Method 3: Use ffmpeg+ffprobe
  145. # Thumbnails attached using this method doesn't show up as cover in some cases
  146. # See https://github.com/yt-dlp/yt-dlp/issues/2125, https://github.com/yt-dlp/yt-dlp/issues/411
  147. if not success:
  148. success = True
  149. try:
  150. options = [*self.stream_copy_opts(), '-map', '1']
  151. old_stream, new_stream = self.get_stream_number(
  152. filename, ('disposition', 'attached_pic'), 1)
  153. if old_stream is not None:
  154. options.extend(['-map', '-0:%d' % old_stream])
  155. new_stream -= 1
  156. options.extend(['-disposition:%s' % new_stream, 'attached_pic'])
  157. self._report_run('ffmpeg', filename)
  158. self.run_ffmpeg_multiple_files([filename, thumbnail_filename], temp_filename, options)
  159. except PostProcessingError as err:
  160. success = False
  161. raise EmbedThumbnailPPError(f'Unable to embed using ffprobe & ffmpeg; {err}')
  162. elif info['ext'] in ['ogg', 'opus', 'flac']:
  163. if not mutagen:
  164. raise EmbedThumbnailPPError('module mutagen was not found. Please install using `python -m pip install mutagen`')
  165. self._report_run('mutagen', filename)
  166. f = {'opus': OggOpus, 'flac': FLAC, 'ogg': OggVorbis}[info['ext']](filename)
  167. pic = Picture()
  168. pic.mime = 'image/%s' % imghdr.what(thumbnail_filename)
  169. with open(thumbnail_filename, 'rb') as thumbfile:
  170. pic.data = thumbfile.read()
  171. pic.type = 3 # front cover
  172. res = self._get_thumbnail_resolution(thumbnail_filename, info['thumbnails'][idx])
  173. if res is not None:
  174. pic.width, pic.height = res
  175. if info['ext'] == 'flac':
  176. f.add_picture(pic)
  177. else:
  178. # https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE
  179. f['METADATA_BLOCK_PICTURE'] = base64.b64encode(pic.write()).decode('ascii')
  180. f.save()
  181. temp_filename = filename
  182. else:
  183. raise EmbedThumbnailPPError('Supported filetypes for thumbnail embedding are: mp3, mkv/mka, ogg/opus/flac, m4a/mp4/mov')
  184. if success and temp_filename != filename:
  185. os.replace(temp_filename, filename)
  186. self.try_utime(filename, mtime, mtime)
  187. converted = original_thumbnail != thumbnail_filename
  188. self._delete_downloaded_files(
  189. thumbnail_filename if converted or not self._already_have_thumbnail else None,
  190. original_thumbnail if converted and not self._already_have_thumbnail else None,
  191. info=info)
  192. return [], info