Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 9 additions & 13 deletions backend/app/downloaders/douyin_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']:
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions backend/app/services/douyin_audio_cache.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions backend/app/services/note.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions backend/tests/test_douyin_audio_source.py
Original file line number Diff line number Diff line change
@@ -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())