feat(sync): 长语音音频本地化(voice_clip media_hash+download+local-first playback)

Mirror download_call_recordings pattern for voice_clips:
- VoiceClip.media_hash column + Alembic migration (c4d5e6f7a8b9)
- VoiceClipOut.media_hash + frontend VoiceClip.media_hash type
- download_voice_clip_audio in app/scraper/media.py (resilient, sha256-dedup,
  same download_url/upsert_media helpers as call recordings)
- sync_voice_clip_audio orchestrator wrapper (commits own SyncRun, mirrors
  sync_call_recordings semantics)
- scheduler: _run_voice_clip_audio + ENTITY_FUNCS['voice_clip_audio'] +
  appended to daily _run_all job (after voice_clips)
- VoiceClipModal playClip local-first: /api/media/{media_hash} with OSS
  oss_file_name fallback (mirrors CallList.playRecord)

Tested: 376 pytest pass; vue-tsc clean; migration applied; 1523/1533 clips
localized (10 pending are genuine upstream HTTP 404s — resilient skip).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-11 12:06:26 +08:00
parent 8b19c299bb
commit 538a0dc635
8 changed files with 210 additions and 4 deletions
+62 -1
View File
@@ -51,7 +51,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Department, SyncRun, WechatAccount, WhatsappAccount
from app.scraper.call import scrape_phone_calls, scrape_sms
from app.scraper.media import download_call_recordings, download_message_media
from app.scraper.media import (
download_call_recordings,
download_message_media,
download_voice_clip_audio,
)
from app.scraper.voice_clip import scrape_voice_clips
from app.scraper.wechat import (
scrape_departments,
@@ -87,12 +91,14 @@ logger = logging.getLogger(__name__)
__all__ = [
"ENTITY_CALL_RECORDINGS",
"ENTITY_VOICE_CLIP_AUDIO",
"SyncResult",
"sync_all_wechat",
"sync_call_recordings",
"sync_departments",
"sync_phone_calls",
"sync_sms",
"sync_voice_clip_audio",
"sync_voice_clips",
"sync_wechat_accounts",
"sync_wechat_friends",
@@ -138,6 +144,11 @@ ENTITY_CALL_RECORDINGS = "call_recordings"
# sync walks every distinct user_id across both staff-account tables and stamps
# the channel locally (WechatAccount -> wechat, WhatsappAccount -> whatsapp).
ENTITY_VOICE_CLIP = "voice_clip"
# Voice-clip audio localization pass: not a scrape — reads already-mirrored
# ``voice_clip`` rows and downloads their OSS ``oss_file_name`` into the local
# object store (records a SyncRun with this entity_type; no SyncState row
# because the voice_clip table itself is the cursor). Mirrors call_recordings.
ENTITY_VOICE_CLIP_AUDIO = "voice_clip_audio"
CHANNEL_WECHAT = "wechat"
CHANNEL_WHATSAPP = "whatsapp"
@@ -1026,6 +1037,56 @@ async def sync_voice_clips(
)
async def sync_voice_clip_audio(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> SyncResult:
"""Download localized copies of voice_clip audio for already-mirrored clips.
Not a scrape: reads local ``voice_clip`` rows whose ``oss_file_name`` (OSS
URL) has no ``media_hash`` yet, downloads each via the public OSS URL, and
links it. Records a ``SyncRun`` (``entity_type='voice_clip_audio'``);
``fetched`` = clips needing download, ``upserted`` = clips localized.
Mirrors :func:`sync_call_recordings` exactly (same commit semantics):
commits its own ``SyncRun`` row because :func:`download_voice_clip_audio`
commits per batch internally, so the run bookkeeping must land in the same
transaction boundary to stay consistent with what was actually persisted.
Failure policy mirrors every other ``sync_*``: a downloader exception is
recorded as a ``status='failed'`` ``SyncRun`` and a failed
:class:`SyncResult` is returned (no re-raise), so a broken audio pass does
NOT abort a multi-entity batch.
"""
run = await _start_run(session, ENTITY_VOICE_CLIP_AUDIO)
try:
attempted, localized = await download_voice_clip_audio(
client, store, session
)
await _finish_run(
session, run, status="success", fetched=attempted, upserted=localized
)
await session.commit()
return SyncResult(
entity=ENTITY_VOICE_CLIP_AUDIO,
status="success",
fetched=attempted,
upserted=localized,
)
except Exception as exc:
logger.exception("sync_voice_clip_audio failed")
await _finish_run(
session, run, status="failed", fetched=0, upserted=0, error=repr(exc)
)
await session.commit()
return SyncResult(
entity=ENTITY_VOICE_CLIP_AUDIO,
status="failed",
fetched=0,
upserted=0,
error=repr(exc),
)
# ---------------------------------------------------------------------------
# Coordinator
# ---------------------------------------------------------------------------