fix: 语音查询支持多分片 media DB + 文件名唯一化

解决 PR #53 review 的阻塞项 #1,顺手修 #3、#6。

#1 `_get_media_db_path()` 硬编码 `media_0.db`
  - 新增模块级 `MEDIA_DB_KEYS`,镜像 `MSG_DB_KEYS` 的分片发现逻辑
  - `_fetch_voice_row` 遍历所有分片,按 `(chat_name_id, local_id)`
    首个命中即返回;单条语音在 media DB 家族内唯一,命中即可停
  - `get_voice_messages` 从每个分片各取 `LIMIT limit`,合并排序后
    截断到 `limit`。选择"每分片取 limit 条再合并"而非"按
    max(create_time) 排序后逐个取到 limit 即停止":后者假设分片
    间时间不重叠,一旦 WeChat 改分片策略就会静默丢消息;前者工作
    量 O(N 分片 × limit),在任何分片布局下都正确

#3 输出文件名冲突
  - `_silk_to_wav` 增加 `local_id` 参数,输出 `{user}_{time}_{lid}.wav`,
    同一秒内两条语音不会互相覆盖;两个调用方都已在作用域内持有
    `local_id`

#6 `_fetch_voice_row` 的 `local_id=None` 死分支
  - 随 #1 的重写一并删除,`local_id` 改为必填位置参数

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
zlab
2026-04-22 22:32:02 -04:00
parent ea1d1157f8
commit c149389174

View File

@@ -1744,8 +1744,21 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str:
DECODED_VOICE_DIR = os.path.join(SCRIPT_DIR, "decoded_voices") DECODED_VOICE_DIR = os.path.join(SCRIPT_DIR, "decoded_voices")
def _get_media_db_path(): # media DB 与 message DB 同样会分片media_0.db、media_1.db…
return _cache.get("message/media_0.db") # 每个分片各有独立的 Name2Id / VoiceInfo 表。
MEDIA_DB_KEYS = sorted([
k for k in ALL_KEYS
if any(v.startswith("message/") for v in key_path_variants(k))
and any(re.search(r"media_\d+\.db$", v) for v in key_path_variants(k))
])
def _iter_media_db_paths():
for rel_key in MEDIA_DB_KEYS:
path = _cache.get(rel_key)
if path:
yield path
def _get_chat_name_id(conn, username): def _get_chat_name_id(conn, username):
row = conn.execute( row = conn.execute(
@@ -1754,32 +1767,31 @@ def _get_chat_name_id(conn, username):
return row[0] if row else None return row[0] if row else None
def _fetch_voice_row(username, local_id=None): def _fetch_voice_row(username, local_id):
"""Query VoiceInfo from media_0.db. Returns (voice_data, create_time) or None.""" """遍历所有 media DB 分片,返回 (voice_data, create_time);找不到返回 None"""
media_db = _get_media_db_path() for media_db in _iter_media_db_paths():
if not media_db:
return None
with closing(sqlite3.connect(media_db)) as conn: with closing(sqlite3.connect(media_db)) as conn:
chat_name_id = _get_chat_name_id(conn, username) chat_name_id = _get_chat_name_id(conn, username)
if chat_name_id is None: if chat_name_id is None:
return None continue
if local_id is not None: row = conn.execute(
return conn.execute(
"SELECT voice_data, create_time FROM VoiceInfo " "SELECT voice_data, create_time FROM VoiceInfo "
"WHERE chat_name_id = ? AND local_id = ?", "WHERE chat_name_id = ? AND local_id = ?",
(chat_name_id, local_id), (chat_name_id, local_id),
).fetchone() ).fetchone()
if row:
return row
return None return None
def _silk_to_wav(voice_data, create_time, username): def _silk_to_wav(voice_data, create_time, username, local_id):
"""Decode SILK voice blob to WAV file, return output path.""" """Decode SILK voice blob to WAV file, return output path."""
import pysilk import pysilk
data = bytes(voice_data) data = bytes(voice_data)
silk_data = data[1:] if data[0] == 0x02 else data silk_data = data[1:] if data[0] == 0x02 else data
os.makedirs(DECODED_VOICE_DIR, exist_ok=True) os.makedirs(DECODED_VOICE_DIR, exist_ok=True)
time_str = datetime.fromtimestamp(create_time).strftime('%Y%m%d_%H%M%S') time_str = datetime.fromtimestamp(create_time).strftime('%Y%m%d_%H%M%S')
out_path = os.path.join(DECODED_VOICE_DIR, f"{username}_{time_str}.wav") out_path = os.path.join(DECODED_VOICE_DIR, f"{username}_{time_str}_{local_id}.wav")
inp = io.BytesIO(silk_data) inp = io.BytesIO(silk_data)
out = io.BytesIO() out = io.BytesIO()
pysilk.decode(inp, out, 24000) pysilk.decode(inp, out, 24000)
@@ -1809,24 +1821,28 @@ def get_voice_messages(chat_name: str, limit: int = 20) -> str:
names = get_contact_names() names = get_contact_names()
display_name = names.get(username, username) display_name = names.get(username, username)
media_db = _get_media_db_path() if not MEDIA_DB_KEYS:
if not media_db: return "找不到 media DB"
return "找不到 media_0.db"
# 从每个分片各取最多 limit 条后合并再截断:分片若有时间重叠也不会漏最新消息
rows = []
for media_db in _iter_media_db_paths():
with closing(sqlite3.connect(media_db)) as conn: with closing(sqlite3.connect(media_db)) as conn:
chat_name_id = _get_chat_name_id(conn, username) chat_name_id = _get_chat_name_id(conn, username)
if chat_name_id is None: if chat_name_id is None:
return f"{display_name} 无语音消息" continue
rows.extend(conn.execute(
rows = conn.execute(
"SELECT local_id, create_time, length(voice_data) FROM VoiceInfo " "SELECT local_id, create_time, length(voice_data) FROM VoiceInfo "
"WHERE chat_name_id = ? ORDER BY create_time DESC LIMIT ?", "WHERE chat_name_id = ? ORDER BY create_time DESC LIMIT ?",
(chat_name_id, limit), (chat_name_id, limit),
).fetchall() ).fetchall())
if not rows: if not rows:
return f"{display_name} 无语音消息" return f"{display_name} 无语音消息"
rows.sort(key=lambda r: r[1], reverse=True)
rows = rows[:limit]
lines = [] lines = []
for local_id, create_time, size in rows: for local_id, create_time, size in rows:
time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M')
@@ -1860,7 +1876,7 @@ def decode_voice(chat_name: str, local_id: int) -> str:
return f"找不到 local_id={local_id} 的语音消息" return f"找不到 local_id={local_id} 的语音消息"
voice_data, create_time = row voice_data, create_time = row
out_path, pcm_len = _silk_to_wav(voice_data, create_time, username) out_path, pcm_len = _silk_to_wav(voice_data, create_time, username, local_id)
duration_s = pcm_len / (24000 * 2) duration_s = pcm_len / (24000 * 2)
return ( return (
f"解码成功!\n" f"解码成功!\n"
@@ -1909,7 +1925,7 @@ def transcribe_voice(chat_name: str, local_id: int) -> str:
return f"找不到 local_id={local_id} 的语音消息" return f"找不到 local_id={local_id} 的语音消息"
voice_data, create_time = row voice_data, create_time = row
wav_path, _ = _silk_to_wav(voice_data, create_time, username) wav_path, _ = _silk_to_wav(voice_data, create_time, username, local_id)
model = _get_whisper_model() model = _get_whisper_model()
result = model.transcribe(wav_path) result = model.transcribe(wav_path)