embedthumbnail.py 10 KB

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