res.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. # -*- coding: UTF-8 -*-
  2. import os
  3. from PIL import Image
  4. import requests
  5. import time
  6. import io
  7. import base64
  8. import logging
  9. logger = logging.getLogger(__name__)
  10. import imghdr
  11. from multiprocessing import Pool
  12. import atexit
  13. from .emoji import EmojiReader
  14. from .avatar import AvatarReader
  15. from .common.textutil import md5 as get_md5_hex, get_file_b64
  16. from .msg import TYPE_SPEAK
  17. from .audio import parse_wechat_audio_file
  18. from .wxgf import WxgfAndroidDecoder, is_wxgf_file
  19. LIB_PATH = os.path.dirname(os.path.abspath(__file__))
  20. VOICE_DIRNAME = 'voice2'
  21. IMG_DIRNAME = 'image2'
  22. EMOJI_DIRNAME = 'emoji'
  23. VIDEO_DIRNAME = 'video'
  24. JPEG_QUALITY = 50
  25. class Resource(object):
  26. """ Multimedia resources parser."""
  27. def __init__(self, parser,
  28. res_dir: str,
  29. *,
  30. wxgf_server: str | None = None,
  31. avt_db: str | None = None):
  32. """
  33. Args:
  34. res_dir: path to the resource directory
  35. wxgf_server: "hostname:port" that points to the wxgf converter android app
  36. avt_db: "avatar.index" file that only exists in old versions of wechat
  37. """
  38. def check(subdir):
  39. dir_to_check = os.path.join(res_dir, subdir)
  40. assert os.path.isdir(dir_to_check), f"No such directory: {dir_to_check}"
  41. [check(k) for k in ['', IMG_DIRNAME, EMOJI_DIRNAME, VOICE_DIRNAME]]
  42. self.res_dir = res_dir
  43. self.parser = parser
  44. self.voice_cache_idx = {}
  45. self.img_dir = os.path.join(res_dir, IMG_DIRNAME)
  46. self.voice_dir = os.path.join(res_dir, VOICE_DIRNAME)
  47. self.video_dir = os.path.join(res_dir, VIDEO_DIRNAME)
  48. self.avt_reader = AvatarReader(res_dir, avt_db)
  49. self.wxgf_decoder = WxgfAndroidDecoder(wxgf_server)
  50. self.emoji_reader = EmojiReader(res_dir, self.parser, wxgf_decoder=self.wxgf_decoder)
  51. def _get_voice_filename(self, imgpath):
  52. fname = get_md5_hex(imgpath.encode('ascii'))
  53. dir1, dir2 = fname[:2], fname[2:4]
  54. ret = os.path.join(self.voice_dir, dir1, dir2,
  55. 'msg_{}.amr'.format(imgpath))
  56. if not os.path.isfile(ret):
  57. logger.error(f"Cannot find voice file {imgpath}, {fname}")
  58. return ""
  59. return ret
  60. def get_voice_mp3(self, imgpath):
  61. """ return mp3 and duration, or empty string and 0 on failure"""
  62. idx = self.voice_cache_idx.get(imgpath)
  63. if idx is None:
  64. return parse_wechat_audio_file(
  65. self._get_voice_filename(imgpath))
  66. return self.voice_cache[idx].get()
  67. def cache_voice_mp3(self, msgs):
  68. """ for speed.
  69. msgs: a collection of WeChatMsg, to cache for later fetch"""
  70. voice_paths = [msg.imgPath for msg in msgs if msg.type == TYPE_SPEAK]
  71. # NOTE: remove all the caching code to debug serial decoding
  72. self.voice_cache_idx = {k: idx for idx, k in enumerate(voice_paths)}
  73. pool = Pool(3)
  74. atexit.register(lambda x: x.terminate(), pool)
  75. self.voice_cache = [pool.apply_async(parse_wechat_audio_file,
  76. (self._get_voice_filename(k),)) for k in voice_paths]
  77. def get_avatar(self, username) -> str:
  78. """ return base64 unicode string"""
  79. im = self.avt_reader.get_avatar(username)
  80. if im is None:
  81. # Try downloading the avatar directly.
  82. avatar_url = self.parser.avatar_urls.get(username)
  83. if avatar_url is None:
  84. return ""
  85. logger.info(f"Requesting avatar of {username} from {avatar_url} ...")
  86. try:
  87. r = requests.get(avatar_url).content
  88. im = Image.open(io.BytesIO(r))
  89. except Exception:
  90. logger.exception(f"Failed to fetch avatar of {username}.")
  91. return ""
  92. else:
  93. self.avt_reader.save_avatar_to_avtdir(username, im)
  94. buf = io.BytesIO()
  95. try:
  96. im.save(buf, 'JPEG', quality=JPEG_QUALITY)
  97. except IOError:
  98. try:
  99. # sometimes it works the second time...
  100. im.save(buf, 'JPEG', quality=JPEG_QUALITY)
  101. except IOError:
  102. return ""
  103. jpeg_str = buf.getvalue()
  104. return base64.b64encode(jpeg_str).decode('ascii')
  105. def _get_img_file(self, fnames):
  106. """ fnames: a list of filename to search for
  107. return (filename, filename) of (big, small) image.
  108. could be empty string.
  109. """
  110. cands = []
  111. for fname in fnames:
  112. dir1, dir2 = fname[:2], fname[2:4]
  113. dirname = os.path.join(self.img_dir, dir1, dir2)
  114. if not os.path.isdir(dirname):
  115. logger.warn("Directory not found: {}".format(dirname))
  116. continue
  117. for f in os.listdir(dirname):
  118. if fname in f:
  119. full_name = os.path.join(dirname, f)
  120. size = os.path.getsize(full_name)
  121. if size > 0:
  122. cands.append((full_name, size))
  123. if not cands:
  124. return ("", "")
  125. cands = sorted(cands, key=lambda x: x[1])
  126. def name_is_thumbnail(name):
  127. return os.path.basename(name).startswith('th_') \
  128. and not name.endswith('hd')
  129. if len(cands) == 1:
  130. name = cands[0][0]
  131. if name_is_thumbnail(name):
  132. # thumbnail
  133. return ("", name)
  134. else:
  135. logger.warn("Found big image but not thumbnail: {}".format(fname))
  136. return (name, "")
  137. big = cands[-1]
  138. ths = list(filter(name_is_thumbnail, [k[0] for k in cands]))
  139. if not ths:
  140. return (big[0], "")
  141. return (big[0], ths[0])
  142. def get_img(self, fnames):
  143. """
  144. :params fnames: possible file paths
  145. :returns: two base64 jpg string
  146. """
  147. fnames = [k for k in fnames if k] # filter out empty string
  148. big_file, small_file = self._get_img_file(fnames)
  149. def get_jpg_b64(img_file):
  150. if not img_file:
  151. return None
  152. # True jpeg. Simplest case.
  153. if img_file.endswith('jpg') and \
  154. imghdr.what(img_file) == 'jpeg':
  155. return get_file_b64(img_file)
  156. if is_wxgf_file(img_file):
  157. start = time.time()
  158. buf = self.wxgf_decoder.decode_with_cache(img_file, None)
  159. if buf is None:
  160. if not self.wxgf_decoder.has_server():
  161. logger.warning("wxgf decoder server is not provided. Cannot decode wxgf images. Please follow instructions to create wxgf decoder server if these images need to be decoded.")
  162. else:
  163. logger.error("Failed to decode wxgf file: {}".format(img_file))
  164. return None
  165. else:
  166. elapsed = time.time() - start
  167. if elapsed > 0.01 and self.wxgf_decoder.has_server():
  168. logger.info(f"Decoded {img_file} in {elapsed:.2f} seconds")
  169. else:
  170. with open(img_file, 'rb') as f:
  171. buf = f.read()
  172. # File is not actually jpeg. Convert.
  173. if imghdr.what(file=None, h=buf) != 'jpeg':
  174. try:
  175. im = Image.open(io.BytesIO(buf))
  176. except:
  177. return None
  178. else:
  179. bufio = io.BytesIO()
  180. im.convert('RGB').save(bufio, 'JPEG', quality=JPEG_QUALITY)
  181. buf = bufio.getvalue()
  182. return base64.b64encode(buf).decode('ascii')
  183. big_file = get_jpg_b64(big_file)
  184. if big_file:
  185. return big_file
  186. return get_jpg_b64(small_file)
  187. def get_emoji_by_md5(self, md5):
  188. """ Returns: (b64 encoded img string, format) """
  189. return self.emoji_reader.get_emoji(md5)
  190. def get_video(self, videoid) -> str | None:
  191. video_file = os.path.join(self.video_dir, videoid + ".mp4")
  192. video_thumbnail_file = os.path.join(self.video_dir, videoid + ".jpg")
  193. if os.path.exists(video_file):
  194. return video_file
  195. elif os.path.exists(video_thumbnail_file):
  196. return video_thumbnail_file
  197. return None