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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user