diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a244c3a..c9dc1ae45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +### Fixed + +- 抖音转写改为从实际视频提取音轨,避免误用独立配乐资源导致漏掉口播;旧任务重试时备份并停用旧音频元数据及转写缓存。 + ## [2.4.4] - 2026-06-23 ### Security diff --git a/backend/app/downloaders/douyin_downloader.py b/backend/app/downloaders/douyin_downloader.py index d0bc9d29b..8f29090dd 100644 --- a/backend/app/downloaders/douyin_downloader.py +++ b/backend/app/downloaders/douyin_downloader.py @@ -10,6 +10,7 @@ from pydantic import BaseModel from app.downloaders.base import Downloader +from app.downloaders.local_downloader import LocalDownloader from app.downloaders.douyin_helper.abogus import ABogus from app.enmus.note_enums import DownloadQuality from app.models.audio_model import AudioDownloadResult @@ -224,19 +225,13 @@ def download( output_dir = self.cache_data os.makedirs(output_dir, exist_ok=True) - output_path = os.path.join(output_dir, "%(id)s.%(ext)s") - video_data = self.fetch_video_info(video_url) - output_path = output_path % { - "id": video_data['aweme_detail']['aweme_id'], - "ext": "mp3", - } - url = video_data['aweme_detail']['music']['play_url']['uri'] - # 下载音频 - audio_data = requests.get(url) - with open(output_path, 'wb') as f: - f.write(audio_data.content) - print(url) + # music.play_url 是配乐资源,不一定包含视频口播;必须提取实际视频音轨。 + video_path = self.download_video(video_url, output_dir=output_dir) + output_path = os.path.join( + output_dir, f"{video_data['aweme_detail']['aweme_id']}_video_audio.mp3" + ) + LocalDownloader().convert_to_mp3(video_path, output_path) tags = [] for tag in video_data['aweme_detail']['video_tag']: if tag['tag_name']: @@ -252,8 +247,9 @@ def download( video_id=video_data['aweme_detail']['aweme_id'], raw_info={ 'tags': video_data['aweme_detail']['caption'] + ''.join(tags), + 'audio_source': 'video_track_v1', }, - video_path=None # ❗音频下载不包含视频路径 + video_path=video_path ) except Exception as e: raise e diff --git a/backend/app/services/douyin_audio_cache.py b/backend/app/services/douyin_audio_cache.py new file mode 100644 index 000000000..57b58eab9 --- /dev/null +++ b/backend/app/services/douyin_audio_cache.py @@ -0,0 +1,21 @@ +"""Retire transcripts derived from the old Douyin music download path.""" +import json +from pathlib import Path +from uuid import uuid4 + + +def retire_legacy_douyin_cache(audio_cache: Path, transcript_cache: Path) -> bool: + if not audio_cache.exists() and not transcript_cache.exists(): + return False + try: + data = json.loads(audio_cache.read_text(encoding="utf-8")) + if data.get("raw_info", {}).get("audio_source") == "video_track_v1": + return False + except (OSError, ValueError, AttributeError): + pass + # Keep old results recoverable, but prevent retries from reusing music transcripts. + suffix = f".legacy-music-{uuid4().hex}.bak" + for path in (transcript_cache, audio_cache): + if path.exists(): + path.rename(path.with_name(path.name + suffix)) + return True diff --git a/backend/app/services/note.py b/backend/app/services/note.py index f493c9104..789216bde 100644 --- a/backend/app/services/note.py +++ b/backend/app/services/note.py @@ -1,4 +1,5 @@ import json +from app.services.douyin_audio_cache import retire_legacy_douyin_cache import logging import os from dataclasses import asdict @@ -133,6 +134,10 @@ def generate( audio_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_audio.json" transcript_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_transcript.json" markdown_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_markdown.md" + if platform == "douyin" and retire_legacy_douyin_cache( + audio_cache_file, transcript_cache_file + ): + logger.info("已备份旧抖音配乐转写缓存,将从视频音轨重新转写") # 1. 获取字幕/转写:优先缓存 → 平台字幕 → 音频转写 transcript = None diff --git a/backend/tests/test_douyin_audio_source.py b/backend/tests/test_douyin_audio_source.py new file mode 100644 index 000000000..f970e3e47 --- /dev/null +++ b/backend/tests/test_douyin_audio_source.py @@ -0,0 +1,52 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from app.downloaders.douyin_downloader import DouyinDownloader +from app.services.douyin_audio_cache import retire_legacy_douyin_cache + + +class DouyinAudioSourceTests(unittest.TestCase): + def test_audio_comes_from_video_not_music(self): + downloader = DouyinDownloader() + detail = { + 'aweme_id': '123', 'item_title': 'demo', 'caption': '', 'video_tag': [], + 'music': {'play_url': {'uri': 'https://example.com/wrong-music'}}, + 'video': {'duration': 85000, 'cover': True, + 'cover_original_scale': {'url_list': ['cover']}}, + } + with tempfile.TemporaryDirectory() as folder, \ + patch.object(downloader, 'fetch_video_info', return_value={'aweme_detail': detail}), \ + patch.object(downloader, 'download_video', return_value='/video.mp4'), \ + patch('app.downloaders.douyin_downloader.LocalDownloader.convert_to_mp3') as convert, \ + patch('app.downloaders.douyin_downloader.requests.get') as request: + result = downloader.download('https://www.douyin.com/video/123', output_dir=folder) + convert.assert_called_once_with('/video.mp4', str(Path(folder) / '123_video_audio.mp3')) + request.assert_not_called() + self.assertEqual(result.raw_info['audio_source'], 'video_track_v1') + self.assertEqual(result.video_path, '/video.mp4') + + def test_old_cache_backed_up_and_new_cache_reused(self): + with tempfile.TemporaryDirectory() as folder: + audio, transcript = Path(folder) / 'audio.json', Path(folder) / 'transcript.json' + audio.write_text(json.dumps({'raw_info': {}})) + transcript.write_text('old transcript') + self.assertTrue(retire_legacy_douyin_cache(audio, transcript)) + self.assertFalse(audio.exists()) + self.assertFalse(transcript.exists()) + backups = list(Path(folder).glob('*.bak')) + self.assertEqual(len(backups), 2) + self.assertIn('old transcript', [p.read_text() for p in backups]) + audio.write_text(json.dumps({'raw_info': {'audio_source': 'video_track_v1'}})) + transcript.write_text('new transcript') + self.assertFalse(retire_legacy_douyin_cache(audio, transcript)) + self.assertEqual(transcript.read_text(), 'new transcript') + + def test_orphan_transcript_not_reused(self): + with tempfile.TemporaryDirectory() as folder: + audio, transcript = Path(folder) / 'audio.json', Path(folder) / 'transcript.json' + transcript.write_text('old transcript') + self.assertTrue(retire_legacy_douyin_cache(audio, transcript)) + self.assertFalse(transcript.exists())