embedthumbnail.py 10 KB

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