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
+3
View File
@@ -275,6 +275,9 @@ class VoiceClipOut(BaseModel):
# OSS recording URL — surfaced so the modal can render an inline
# ``<audio controls>`` player (see spec §6).
oss_file_name: str | None = None
# sha256 media_hash of the localized audio (NULL until downloaded). Playback
# prefers /api/media/{this} over the OSS oss_file_name URL (local-first).
media_hash: str | None = None
mp3_duration: int | None = None
duration_format: str | None = None
# LOCAL-only 手动匹配 (spec §7): the customer.friend_id this clip has been