improvement(media): 聊天媒体支持 file_id 兜底定位

- 图片/视频消息无 MD5 时,解析并下发 file_id,用于本地资源兜底定位与展示
- 后端 chat_media/open_folder 支持 md5/file_id;视频优先可 Range 的文件响应,并在需要时解密落盘
- 前端聊天页与 API 调用适配 file_id;补充媒体 URL 可用性判断
- 解密页补充“获取密钥”提示,支持手动输入/保存密钥;README 同步说明;更新音频图标资源
This commit is contained in:
2977094657
2025-12-23 16:39:38 +08:00
parent a4d652230f
commit 36f5067730
9 changed files with 790 additions and 126 deletions

View File

@@ -1,5 +1,6 @@
import ctypes
import datetime
import glob
import hashlib
import json
import mimetypes
@@ -371,7 +372,7 @@ def _resolve_media_path_from_hardlink(
quoted = _quote_ident(table_name)
try:
row = conn.execute(
f"SELECT dir1, dir2, file_name FROM {quoted} WHERE md5 = ? ORDER BY modify_time DESC LIMIT 1",
f"SELECT dir1, dir2, file_name, modify_time FROM {quoted} WHERE md5 = ? ORDER BY modify_time DESC LIMIT 1",
(md5,),
).fetchone()
except Exception:
@@ -383,6 +384,132 @@ def _resolve_media_path_from_hardlink(
if not file_name:
continue
if kind_key in {"video", "video_thumb"}:
roots: list[Path] = []
for r in [wxid_dir] + (extra_roots or []):
if not r:
continue
try:
rr = r.resolve()
except Exception:
rr = r
if rr not in roots:
roots.append(rr)
def _iter_video_base_dirs(r: Path) -> list[Path]:
bases: list[Path] = []
try:
if r.exists() and r.is_dir():
pass
else:
return bases
except Exception:
return bases
candidates = [
r / "msg" / "video",
r / "video",
r if str(r.name).lower() == "video" else None,
]
for c in candidates:
if not c:
continue
try:
if c.exists() and c.is_dir():
bases.append(c)
except Exception:
continue
# de-dup while keeping order
seen: set[str] = set()
uniq: list[Path] = []
for b in bases:
try:
k = str(b.resolve())
except Exception:
k = str(b)
if k in seen:
continue
seen.add(k)
uniq.append(b)
return uniq
modify_time = None
try:
if row["modify_time"] is not None:
modify_time = int(row["modify_time"])
except Exception:
modify_time = None
guessed_month: Optional[str] = None
if modify_time and modify_time > 0:
try:
dt = datetime.datetime.fromtimestamp(int(modify_time))
guessed_month = f"{dt.year:04d}-{dt.month:02d}"
except Exception:
guessed_month = None
stem = Path(file_name).stem
if kind_key == "video":
file_variants = [file_name]
else:
# Prefer real thumbnails when possible.
file_variants = [
f"{stem}_thumb.jpg",
f"{stem}_thumb.jpeg",
f"{stem}_thumb.png",
f"{stem}_thumb.webp",
f"{stem}.jpg",
f"{stem}.jpeg",
f"{stem}.png",
f"{stem}.gif",
f"{stem}.webp",
f"{stem}.dat",
file_name,
]
for root in roots:
for base_dir in _iter_video_base_dirs(root):
dirs_to_check: list[Path] = []
if guessed_month:
dirs_to_check.append(base_dir / guessed_month)
dirs_to_check.append(base_dir)
for d in dirs_to_check:
try:
if not d.exists() or not d.is_dir():
continue
except Exception:
continue
for fv in file_variants:
p = d / fv
try:
if p.exists() and p.is_file():
return p
except Exception:
continue
# Fallback: scan within the month directory for the exact file_name.
if guessed_month:
try:
for p in d.rglob(file_name):
try:
if p.is_file():
return p
except Exception:
continue
except Exception:
pass
# Final fallback: locate by name under msg/video and cache.
for base in _iter_video_base_dirs(wxid_dir):
try:
for p in base.rglob(file_name):
if p.is_file():
return p
except Exception:
pass
return None
if kind_key == "file":
try:
full_row = conn.execute(
@@ -973,20 +1100,38 @@ def _get_wechat_v2_ciphertext(weixin_root: Path, most_common_last2: bytes) -> Op
template_files.sort(key=_extract_yyyymm_for_sort, reverse=True)
sig = b"\x07\x08V2\x08\x07"
for file in template_files:
def try_read_ct(file: Path, require_last2: bool) -> Optional[bytes]:
try:
with open(file, "rb") as f:
if f.read(6) != sig:
continue
f.seek(-2, 2)
if f.read(2) != most_common_last2:
continue
return None
if require_last2 and most_common_last2 and len(most_common_last2) == 2:
try:
f.seek(-2, 2)
if f.read(2) != most_common_last2:
return None
except Exception:
return None
f.seek(0xF)
ct = f.read(16)
if ct and len(ct) == 16:
return ct
except Exception:
continue
return None
return None
# Prefer matching last2 bytes (older heuristic), but fall back to any V2 template like wx_key.
if most_common_last2 and len(most_common_last2) == 2:
for file in template_files:
ct = try_read_ct(file, require_last2=True)
if ct:
return ct
for file in template_files:
ct = try_read_ct(file, require_last2=False)
if ct:
return ct
return None
@@ -1120,8 +1265,6 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
MEM_MAPPED = 0x40000
MEM_IMAGE = 0x1000000
PAGE_NOACCESS = 0x01
PAGE_READONLY = 0x02
@@ -1172,7 +1315,9 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
return False
return bool(protect & readable_mask)
pattern = re.compile(rb"(?i)(?<![0-9a-z])([0-9a-z]{16}|[0-9a-z]{32})(?![0-9a-z])")
# Keep pattern consistent with wx_key: search for 16/32 lower/upper alpha-num strings with word-boundary-like guards.
# (Using 32 first reduces false positives in some builds.)
pattern = re.compile(rb"(?i)(?<![0-9a-z])([0-9a-z]{32}|[0-9a-z]{16})(?![0-9a-z])")
def scan_pid(pid: int) -> Optional[bytes]:
handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid)
@@ -1198,14 +1343,18 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
to_read = min(chunk, region_size - offset)
b = read_mem(base + offset, int(to_read))
if not b:
return None
# Don't abort the whole region on a single read failure (wx_key keeps scanning).
offset += to_read
tail = b""
continue
data = tail + b
for m in pattern.finditer(data):
cand = m.group(1)
if len(cand) == 16:
candidates = [cand]
else:
if len(cand) == 32:
# wx_key uses key[:16] to validate; keep that but also try the second half for compatibility.
candidates = [cand[:16], cand[16:]]
else:
candidates = [cand]
for cand16 in candidates:
if _verify_wechat_aes_key(ciphertext, cand16):
return cand16
@@ -1219,13 +1368,15 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
try:
while VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)):
try:
if int(mbi.State) == MEM_COMMIT and int(mbi.Type) in {MEM_PRIVATE, MEM_MAPPED, MEM_IMAGE}:
if int(mbi.State) == MEM_COMMIT and int(mbi.Type) == MEM_PRIVATE:
protect = int(mbi.Protect)
if is_readable(protect):
base = int(mbi.BaseAddress)
size = int(mbi.RegionSize)
if size > 0:
regions.append((base, size))
# Skip extremely large regions to keep runtime bounded (same idea as wx_key).
if size <= 100 * 1024 * 1024:
regions.append((base, size))
addr = int(mbi.BaseAddress) + int(mbi.RegionSize)
except Exception:
addr += 0x1000
@@ -1250,6 +1401,100 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
return None
@lru_cache(maxsize=4096)
def _fallback_search_media_by_file_id(
weixin_root_str: str,
file_id: str,
kind: str = "",
username: str = "",
) -> Optional[str]:
"""在微信数据目录里按文件名file_id兜底查找媒体文件。
一些微信版本的图片消息不再直接提供 32 位 MD5而是提供形如 `cdnthumburl` 的长串标识,
本函数用于按文件名/前缀在 msg/attach、cache 等目录中定位对应的 .dat 资源文件。
"""
if not weixin_root_str or not file_id:
return None
try:
root = Path(weixin_root_str)
except Exception:
return None
kind_key = str(kind or "").lower().strip()
fid = str(file_id or "").strip()
if not fid:
return None
# 优先在当前会话的 attach 子目录中查找(显著减少扫描范围)
search_dirs: list[Path] = []
if username:
try:
chat_hash = hashlib.md5(str(username).encode()).hexdigest()
search_dirs.append(root / "msg" / "attach" / chat_hash)
except Exception:
pass
if kind_key == "file":
search_dirs.extend([root / "msg" / "file"])
elif kind_key == "video" or kind_key == "video_thumb":
search_dirs.extend([root / "msg" / "video", root / "cache"])
else:
search_dirs.extend([root / "msg" / "attach", root / "cache", root / "msg" / "file", root / "msg" / "video"])
# de-dup while keeping order
seen: set[str] = set()
uniq_dirs: list[Path] = []
for d in search_dirs:
try:
k = str(d.resolve())
except Exception:
k = str(d)
if k in seen:
continue
seen.add(k)
uniq_dirs.append(d)
base = glob.escape(fid)
has_suffix = bool(Path(fid).suffix)
patterns: list[str] = []
if has_suffix:
patterns.append(base)
else:
patterns.extend(
[
f"{base}_h.dat",
f"{base}_t.dat",
f"{base}.dat",
f"{base}*.dat",
f"{base}.jpg",
f"{base}.jpeg",
f"{base}.png",
f"{base}.gif",
f"{base}.webp",
f"{base}*",
]
)
for d in uniq_dirs:
try:
if not d.exists() or not d.is_dir():
continue
except Exception:
continue
for pat in patterns:
try:
for p in d.rglob(pat):
try:
if p.is_file():
return str(p)
except Exception:
continue
except Exception:
continue
return None
def _save_media_keys(account_dir: Path, xor_key: int, aes_key16: bytes) -> None:
try:
payload = {

View File

@@ -323,8 +323,11 @@ async def list_chat_messages(
emoji_url = ""
thumb_url = ""
image_url = ""
image_file_id = ""
video_md5 = ""
video_thumb_md5 = ""
video_file_id = ""
video_thumb_file_id = ""
video_url = ""
video_thumb_url = ""
voice_length = ""
@@ -337,6 +340,7 @@ async def list_chat_messages(
transfer_status = ""
file_md5 = ""
transfer_id = ""
voip_type = ""
if local_type == 10000:
render_type = "system"
@@ -396,14 +400,40 @@ async def list_chat_messages(
quote_content = str(parsed.get("quoteContent") or "")
elif local_type == 3:
render_type = "image"
image_md5 = _extract_xml_attr(raw_text, "md5")
# Extract CDN URL and validate it looks like a proper URL
_cdn_url = (
# 先尝试从 XML 中提取 md5不同版本字段可能不同
image_md5 = _extract_xml_attr(raw_text, "md5") or _extract_xml_tag_text(raw_text, "md5")
if not image_md5:
for k in [
"cdnthumbmd5",
"cdnthumd5",
"cdnmidimgmd5",
"cdnbigimgmd5",
"hdmd5",
"hevc_mid_md5",
"hevc_md5",
"imgmd5",
"filemd5",
]:
image_md5 = _extract_xml_attr(raw_text, k) or _extract_xml_tag_text(raw_text, k)
if image_md5:
break
# Extract CDN URL (some versions store a non-HTTP "file id" string here)
_cdn_url_or_id = (
_extract_xml_attr(raw_text, "cdnthumburl")
or _extract_xml_attr(raw_text, "cdnthumurl")
or _extract_xml_attr(raw_text, "cdnmidimgurl")
or _extract_xml_attr(raw_text, "cdnbigimgurl")
or _extract_xml_tag_text(raw_text, "cdnthumburl")
or _extract_xml_tag_text(raw_text, "cdnthumurl")
or _extract_xml_tag_text(raw_text, "cdnmidimgurl")
or _extract_xml_tag_text(raw_text, "cdnbigimgurl")
)
image_url = _cdn_url if _cdn_url.startswith(("http://", "https://")) else ""
_cdn_url_or_id = str(_cdn_url_or_id or "").strip()
image_url = _cdn_url_or_id if _cdn_url_or_id.startswith(("http://", "https://")) else ""
if (not image_url) and _cdn_url_or_id:
image_file_id = _cdn_url_or_id
if (not image_md5) and resource_conn is not None:
image_md5 = _lookup_resource_md5(
resource_conn,
@@ -423,8 +453,25 @@ async def list_chat_messages(
render_type = "video"
video_md5 = _extract_xml_attr(raw_text, "md5")
video_thumb_md5 = _extract_xml_attr(raw_text, "cdnthumbmd5")
video_thumb_url = _extract_xml_attr(raw_text, "cdnthumburl")
video_url = _extract_xml_attr(raw_text, "cdnvideourl")
video_thumb_url_or_id = _extract_xml_attr(raw_text, "cdnthumburl") or _extract_xml_tag_text(
raw_text, "cdnthumburl"
)
video_url_or_id = _extract_xml_attr(raw_text, "cdnvideourl") or _extract_xml_tag_text(
raw_text, "cdnvideourl"
)
video_thumb_url = (
video_thumb_url_or_id
if str(video_thumb_url_or_id or "").strip().lower().startswith(("http://", "https://"))
else ""
)
video_url = (
video_url_or_id
if str(video_url_or_id or "").strip().lower().startswith(("http://", "https://"))
else ""
)
video_thumb_file_id = "" if video_thumb_url else (str(video_thumb_url_or_id or "").strip() or "")
video_file_id = "" if video_url else (str(video_url_or_id or "").strip() or "")
if (not video_thumb_md5) and resource_conn is not None:
video_thumb_md5 = _lookup_resource_md5(
resource_conn,
@@ -453,6 +500,29 @@ async def list_chat_messages(
create_time=create_time,
)
content_text = "[表情]"
elif local_type == 50:
render_type = "voip"
try:
import re
block = raw_text
m_voip = re.search(
r"(<VoIPBubbleMsg[^>]*>.*?</VoIPBubbleMsg>)",
raw_text,
flags=re.IGNORECASE | re.DOTALL,
)
if m_voip:
block = m_voip.group(1) or raw_text
room_type = str(_extract_xml_tag_text(block, "room_type") or "").strip()
if room_type == "0":
voip_type = "video"
elif room_type == "1":
voip_type = "audio"
voip_msg = str(_extract_xml_tag_text(block, "msg") or "").strip()
content_text = voip_msg or "通话"
except Exception:
content_text = "通话"
elif local_type != 1:
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
@@ -513,15 +583,19 @@ async def list_chat_messages(
"title": title,
"url": url,
"imageMd5": image_md5,
"imageFileId": image_file_id,
"emojiMd5": emoji_md5,
"emojiUrl": emoji_url,
"thumbUrl": thumb_url,
"imageUrl": image_url,
"videoMd5": video_md5,
"videoThumbMd5": video_thumb_md5,
"videoFileId": video_file_id,
"videoThumbFileId": video_thumb_file_id,
"videoUrl": video_url,
"videoThumbUrl": video_thumb_url,
"voiceLength": voice_length,
"voipType": voip_type,
"quoteTitle": quote_title,
"quoteContent": quote_content,
"amount": amount,
@@ -632,12 +706,19 @@ async def list_chat_messages(
try:
rt = str(m.get("renderType") or "")
if rt == "image":
if (not str(m.get("imageUrl") or "")) and str(m.get("imageMd5") or ""):
md5 = str(m.get("imageMd5") or "")
m["imageUrl"] = (
base_url
+ f"/api/chat/media/image?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
if not str(m.get("imageUrl") or ""):
md5 = str(m.get("imageMd5") or "").strip()
file_id = str(m.get("imageFileId") or "").strip()
if md5:
m["imageUrl"] = (
base_url
+ f"/api/chat/media/image?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
elif file_id:
m["imageUrl"] = (
base_url
+ f"/api/chat/media/image?account={quote(account_dir.name)}&file_id={quote(file_id)}&username={quote(username)}"
)
elif rt == "emoji":
md5 = str(m.get("emojiMd5") or "")
if md5:
@@ -667,18 +748,37 @@ async def list_chat_messages(
+ f"/api/chat/media/emoji?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
elif rt == "video":
if (not str(m.get("videoThumbUrl") or "")) and str(m.get("videoThumbMd5") or ""):
md5 = str(m.get("videoThumbMd5") or "")
m["videoThumbUrl"] = (
base_url
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
if (not str(m.get("videoUrl") or "")) and str(m.get("videoMd5") or ""):
md5 = str(m.get("videoMd5") or "")
m["videoUrl"] = (
base_url
+ f"/api/chat/media/video?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
video_thumb_url = str(m.get("videoThumbUrl") or "").strip()
video_thumb_md5 = str(m.get("videoThumbMd5") or "").strip()
video_thumb_file_id = str(m.get("videoThumbFileId") or "").strip()
if (not video_thumb_url) or (
not video_thumb_url.lower().startswith(("http://", "https://"))
):
if video_thumb_md5:
m["videoThumbUrl"] = (
base_url
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&md5={quote(video_thumb_md5)}&username={quote(username)}"
)
elif video_thumb_file_id:
m["videoThumbUrl"] = (
base_url
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&file_id={quote(video_thumb_file_id)}&username={quote(username)}"
)
video_url = str(m.get("videoUrl") or "").strip()
video_md5 = str(m.get("videoMd5") or "").strip()
video_file_id = str(m.get("videoFileId") or "").strip()
if (not video_url) or (not video_url.lower().startswith(("http://", "https://"))):
if video_md5:
m["videoUrl"] = (
base_url
+ f"/api/chat/media/video?account={quote(account_dir.name)}&md5={quote(video_md5)}&username={quote(username)}"
)
elif video_file_id:
m["videoUrl"] = (
base_url
+ f"/api/chat/media/video?account={quote(account_dir.name)}&file_id={quote(video_file_id)}&username={quote(username)}"
)
elif rt == "voice":
if str(m.get("serverId") or ""):
sid = int(m.get("serverId") or 0)

View File

@@ -19,6 +19,7 @@ from ..media_helpers import (
_detect_image_extension,
_detect_image_media_type,
_ensure_decrypted_resource_for_md5,
_fallback_search_media_by_file_id,
_fallback_search_media_by_md5,
_get_decrypted_resource_path,
_get_resource_dir,
@@ -238,29 +239,32 @@ async def download_chat_emoji(req: EmojiDownloadRequest):
@router.get("/api/chat/media/image", summary="获取图片消息资源")
async def get_chat_image(md5: str, account: Optional[str] = None, username: Optional[str] = None):
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
async def get_chat_image(
md5: Optional[str] = None,
file_id: Optional[str] = None,
account: Optional[str] = None,
username: Optional[str] = None,
):
if (not md5) and (not file_id):
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
account_dir = _resolve_account_dir(account)
# 优先从解密资源目录读取(更快)
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
if media_type == "application/octet-stream":
guessed = mimetypes.guess_type(str(decrypted_path))[0]
if guessed:
media_type = guessed
return Response(content=data, media_type=media_type)
# md5 模式:优先从解密资源目录读取(更快)
if md5:
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
if media_type == "application/octet-stream":
guessed = mimetypes.guess_type(str(decrypted_path))[0]
if guessed:
media_type = guessed
return Response(content=data, media_type=media_type)
# 回退到原始逻辑:从微信数据目录实时解密
# 回退:从微信数据目录实时定位并解密
wxid_dir = _resolve_account_wxid_dir(account_dir)
hardlink_db_path = account_dir / "hardlink.db"
extra_roots: list[Path] = []
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
if db_storage_dir:
extra_roots.append(db_storage_dir)
roots: list[Path] = []
if wxid_dir:
@@ -271,30 +275,47 @@ async def get_chat_image(md5: str, account: Optional[str] = None, username: Opti
roots.append(wxid_dir / "cache")
if db_storage_dir:
roots.append(db_storage_dir)
if not roots:
raise HTTPException(
status_code=404,
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
)
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=str(md5),
kind="image",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5))
if hit:
p = Path(hit)
p: Optional[Path] = None
if md5:
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=str(md5),
kind="image",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5), kind="image")
if hit:
p = Path(hit)
elif file_id:
# 一些版本图片消息无 MD5仅提供 cdnthumburl 等“文件标识”
for r in [wxid_dir, db_storage_dir]:
if not r:
continue
hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="image", username=str(username or ""))
if hit:
p = Path(hit)
break
if not p:
raise HTTPException(status_code=404, detail="Image not found.")
logger.info(f"chat_image: md5={md5} resolved_source={p}")
logger.info(f"chat_image: md5={md5} file_id={file_id} resolved_source={p}")
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
if media_type.startswith("image/"):
# 仅在 md5 有效时缓存到 resource 目录file_id 可能非常长,避免写入超长文件名
if md5 and media_type.startswith("image/"):
try:
out_md5 = str(md5).lower()
ext = _detect_image_extension(data)
@@ -304,7 +325,8 @@ async def get_chat_image(md5: str, account: Optional[str] = None, username: Opti
out_path.write_bytes(data)
except Exception:
pass
logger.info(f"chat_image: md5={md5} media_type={media_type} bytes={len(data)}")
logger.info(f"chat_image: md5={md5} file_id={file_id} media_type={media_type} bytes={len(data)}")
return Response(content=data, media_type=media_type)
@@ -345,17 +367,23 @@ async def get_chat_emoji(md5: str, account: Optional[str] = None, username: Opti
@router.get("/api/chat/media/video_thumb", summary="获取视频缩略图资源")
async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username: Optional[str] = None):
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
async def get_chat_video_thumb(
md5: Optional[str] = None,
file_id: Optional[str] = None,
account: Optional[str] = None,
username: Optional[str] = None,
):
if (not md5) and (not file_id):
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
account_dir = _resolve_account_dir(account)
# 优先从解密资源目录读取(更快)
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
return Response(content=data, media_type=media_type)
if md5:
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
return Response(content=data, media_type=media_type)
# 回退到原始逻辑
wxid_dir = _resolve_account_wxid_dir(account_dir)
@@ -375,18 +403,28 @@ async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username
status_code=404,
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
)
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=str(md5),
kind="video_thumb",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5))
if hit:
p = Path(hit)
p: Optional[Path] = None
if md5:
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=str(md5),
kind="video_thumb",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5), kind="video_thumb")
if hit:
p = Path(hit)
if (not p) and file_id:
for r in [wxid_dir, db_storage_dir]:
if not r:
continue
hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="video_thumb", username=str(username or ""))
if hit:
p = Path(hit)
break
if not p:
raise HTTPException(status_code=404, detail="Video thumbnail not found.")
@@ -395,10 +433,24 @@ async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username
@router.get("/api/chat/media/video", summary="获取视频资源")
async def get_chat_video(md5: str, account: Optional[str] = None, username: Optional[str] = None):
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
async def get_chat_video(
md5: Optional[str] = None,
file_id: Optional[str] = None,
account: Optional[str] = None,
username: Optional[str] = None,
):
if (not md5) and (not file_id):
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
account_dir = _resolve_account_dir(account)
md5_norm = str(md5 or "").strip().lower() if md5 else ""
if md5_norm:
# 优先从解密资源目录读取(更快,且支持 Range
decrypted_path = _try_find_decrypted_resource(account_dir, md5_norm)
if decrypted_path:
mt = _guess_media_type_by_path(decrypted_path, fallback="video/mp4")
return FileResponse(str(decrypted_path), media_type=mt)
wxid_dir = _resolve_account_wxid_dir(account_dir)
hardlink_db_path = account_dir / "hardlink.db"
extra_roots: list[Path] = []
@@ -416,22 +468,61 @@ async def get_chat_video(md5: str, account: Optional[str] = None, username: Opti
status_code=404,
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
)
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=str(md5),
kind="video",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5))
if hit:
p = Path(hit)
p: Optional[Path] = None
if md5_norm:
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=md5_norm,
kind="video",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), md5_norm, kind="video")
if hit:
p = Path(hit)
if (not p) and file_id:
for r in [wxid_dir, db_storage_dir]:
if not r:
continue
hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="video", username=str(username or ""))
if hit:
p = Path(hit)
break
if not p:
raise HTTPException(status_code=404, detail="Video not found.")
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
return FileResponse(str(p), media_type=media_type)
# 直接可播放的 MP4直接 FileResponse支持 Range
try:
with open(p, "rb") as f:
head = f.read(8)
if len(head) >= 8 and head[4:8] == b"ftyp":
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
return FileResponse(str(p), media_type=media_type)
except Exception:
pass
# 尝试解密/去前缀并落盘(避免一次性返回大文件 bytes
if md5_norm:
try:
materialized = _ensure_decrypted_resource_for_md5(
account_dir,
md5=md5_norm,
source_path=p,
weixin_root=wxid_dir,
)
except Exception:
materialized = None
if materialized:
media_type = _guess_media_type_by_path(materialized, fallback="video/mp4")
return FileResponse(str(materialized), media_type=media_type)
# 最后兜底:直接返回处理后的 bytes不支持 Range
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
if media_type == "application/octet-stream":
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
return Response(content=data, media_type=media_type)
@router.get("/api/chat/media/voice", summary="获取语音消息资源")
@@ -482,6 +573,7 @@ async def get_chat_voice(server_id: int, account: Optional[str] = None):
async def open_chat_media_folder(
kind: str,
md5: Optional[str] = None,
file_id: Optional[str] = None,
server_id: Optional[int] = None,
account: Optional[str] = None,
username: Optional[str] = None,
@@ -528,15 +620,36 @@ async def open_chat_media_folder(
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to export voice: {e}")
else:
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
p = _resolve_media_path_for_kind(account_dir, kind=kind_key, md5=str(md5), username=username)
if not md5 and not file_id:
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
if md5 and (not file_id) and (not _is_valid_md5(str(md5))):
file_id = str(md5)
md5 = None
if md5:
p = _resolve_media_path_for_kind(account_dir, kind=kind_key, md5=str(md5), username=username)
if (not p) and file_id:
wxid_dir = _resolve_account_wxid_dir(account_dir)
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
for r in [wxid_dir, db_storage_dir]:
if not r:
continue
hit = _fallback_search_media_by_file_id(
str(r),
str(file_id),
kind=str(kind_key),
username=str(username or ""),
)
if hit:
p = Path(hit)
break
resolved_before_materialize = p
materialized_ok = False
opened_kind = "resolved"
if p and kind_key in {"image", "emoji", "video_thumb"}:
if p and kind_key in {"image", "emoji", "video_thumb"} and md5:
wxid_dir = _resolve_account_wxid_dir(account_dir)
source_path = p
if kind_key == "emoji":
@@ -693,7 +806,7 @@ async def open_chat_media_folder(
except Exception:
target = str(p)
logger.info(f"open_folder: kind={kind_key} md5={md5} server_id={server_id} -> {target}")
logger.info(f"open_folder: kind={kind_key} md5={md5} file_id={file_id} server_id={server_id} -> {target}")
if os.name != "nt":
raise HTTPException(status_code=400, detail="open_folder is only supported on Windows.")

View File

@@ -98,7 +98,7 @@ async def get_media_keys(account: Optional[str] = None, force_extract: bool = Fa
# 保存密钥到缓存
_save_media_keys(account_dir, xor_key, aes_key16)
else:
aes_message = "无法从微信进程提取AES密钥请确认微信正在运行并尝试以管理员身份运行后端部分新版微信可能暂不兼容"
aes_message = "无法从微信进程提取AES密钥请确认微信正在运行并尝试以管理员身份运行后端可尝试打开朋友圈图片并点开大图 2-3 次后再提取"
else:
aes_message = "未找到V2加密模板文件"
else: