mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-07-07 15:41:22 +08:00
first commit
This commit is contained in:
23
backend/.env.example
Normal file
23
backend/.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
# 通用
|
||||
ENV=production
|
||||
API_BASE_URL=http://127.0.0.1:8000
|
||||
SCREENSHOT_BASE_URL=http://127.0.0.1:8000/static/screenshots
|
||||
STATIC=/static # 外部访问路径(URL 前缀)
|
||||
OUT_DIR=./static/screenshots # 本地输出目录
|
||||
IMAGE_BASE_URL=/static/screenshots # 图片访问 URL
|
||||
DATA_DIR=data
|
||||
|
||||
# 后端专用
|
||||
|
||||
# AI 相关配置
|
||||
OPENAI_API_KEY = ""
|
||||
OPENAI_API_BASE_URL = ""
|
||||
OPENAI_MODEL = ""
|
||||
DEEP_SEEK_API_KEY = ""
|
||||
DEEP_SEEK_API_BASE_URL = ""
|
||||
DEEP_SEEK_MODEL = ""
|
||||
QWEN_API_KEY = ""
|
||||
QWEN_API_BASE_URL = ""
|
||||
QWEN_MODEL = ""
|
||||
|
||||
20
backend/Dockerfile
Normal file
20
backend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
|
||||
RUN rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 确保 PATH 中包含 ffmpeg 路径(可选)
|
||||
ENV PATH="/usr/bin:${PATH}"
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./backend /app
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
8
backend/app/__init__.py
Normal file
8
backend/app/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import FastAPI
|
||||
from .routers import note
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="BiliNote")
|
||||
app.include_router(note.router, prefix="/api")
|
||||
return app
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
4
backend/app/db/sqlite_client.py
Normal file
4
backend/app/db/sqlite_client.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import sqlite3
|
||||
|
||||
def get_connection():
|
||||
return sqlite3.connect("note_tasks.db")
|
||||
52
backend/app/db/video_task_dao.py
Normal file
52
backend/app/db/video_task_dao.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from .sqlite_client import get_connection
|
||||
|
||||
def init_video_task_table():
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS video_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def insert_video_task(video_id: str, platform: str, task_id: str):
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO video_tasks (video_id, platform, task_id)
|
||||
VALUES (?, ?, ?)
|
||||
""", (video_id, platform, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_task_by_video(video_id: str, platform: str):
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT task_id FROM video_tasks
|
||||
WHERE video_id = ? AND platform = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""", (video_id, platform))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
return result[0] if result else None
|
||||
|
||||
|
||||
def delete_task_by_video(video_id: str, platform: str):
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
DELETE FROM video_tasks
|
||||
WHERE video_id = ? AND platform = ?
|
||||
""", (video_id, platform))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
0
backend/app/decorators/__init__.py
Normal file
0
backend/app/decorators/__init__.py
Normal file
13
backend/app/decorators/timeit.py
Normal file
13
backend/app/decorators/timeit.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import time
|
||||
import functools
|
||||
|
||||
def timeit(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
result = func(*args, **kwargs)
|
||||
end = time.perf_counter()
|
||||
duration = end - start
|
||||
print(f"⏱️ {func.__name__} executed in {duration:.4f} seconds")
|
||||
return result
|
||||
return wrapper
|
||||
0
backend/app/downloaders/__init__.py
Normal file
0
backend/app/downloaders/__init__.py
Normal file
38
backend/app/downloaders/base.py
Normal file
38
backend/app/downloaders/base.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import enum
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Union
|
||||
|
||||
from app.enmus.note_enums import DownloadQuality
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from os import getenv
|
||||
QUALITY_MAP = {
|
||||
"fast": "32",
|
||||
"medium": "64",
|
||||
"slow": "128"
|
||||
}
|
||||
|
||||
|
||||
class Downloader(ABC):
|
||||
def __init__(self):
|
||||
#TODO 需要修改为可配置
|
||||
self.quality = QUALITY_MAP.get('fast')
|
||||
self.cache_data=getenv('DATA_DIR')
|
||||
|
||||
@abstractmethod
|
||||
def download(self, video_url: str, output_dir: str = None,
|
||||
quality: DownloadQuality = "fast", need_video: Optional[bool] = False) -> AudioDownloadResult:
|
||||
'''
|
||||
|
||||
:param need_video:
|
||||
:param video_url: 资源链接
|
||||
:param output_dir: 输出路径 默认根目录data
|
||||
:param quality: 音频质量 fast | medium | slow
|
||||
:return:返回一个 AudioDownloadResult 类
|
||||
'''
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def download_video(self, video_url: str,
|
||||
output_dir: Union[str, None] = None) -> str:
|
||||
pass
|
||||
104
backend/app/downloaders/bilibili_downloader.py
Normal file
104
backend/app/downloaders/bilibili_downloader.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import os
|
||||
from abc import ABC
|
||||
from typing import Union, Optional
|
||||
|
||||
import yt_dlp
|
||||
|
||||
from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.utils.path_helper import get_data_dir
|
||||
|
||||
|
||||
class BilibiliDownloader(Downloader, ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def download(
|
||||
self,
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
quality: DownloadQuality = "fast",
|
||||
need_video:Optional[bool]=False
|
||||
) -> AudioDownloadResult:
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
if not output_dir:
|
||||
output_dir=self.cache_data
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
||||
'outtmpl': output_path,
|
||||
'postprocessors': [
|
||||
{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '64',
|
||||
}
|
||||
],
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
video_id = info.get("id")
|
||||
title = info.get("title")
|
||||
duration = info.get("duration", 0)
|
||||
cover_url = info.get("thumbnail")
|
||||
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
|
||||
|
||||
return AudioDownloadResult(
|
||||
file_path=audio_path,
|
||||
title=title,
|
||||
duration=duration,
|
||||
cover_url=cover_url,
|
||||
platform="bilibili",
|
||||
video_id=video_id,
|
||||
raw_info=info,
|
||||
video_path=None # ❗音频下载不包含视频路径
|
||||
)
|
||||
|
||||
def download_video(
|
||||
self,
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
) -> str:
|
||||
"""
|
||||
下载视频,返回视频文件路径
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'bv*[ext=mp4]/bestvideo+bestaudio/best',
|
||||
'outtmpl': output_path,
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
'merge_output_format': 'mp4', # 确保合并成 mp4
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
video_id = info.get("id")
|
||||
video_path = os.path.join(output_dir, f"{video_id}.mp4")
|
||||
|
||||
if not os.path.exists(video_path):
|
||||
raise FileNotFoundError(f"视频文件未找到: {video_path}")
|
||||
|
||||
return video_path
|
||||
|
||||
def delete_video(self, video_path: str) -> str:
|
||||
"""
|
||||
删除视频文件
|
||||
"""
|
||||
if os.path.exists(video_path):
|
||||
os.remove(video_path)
|
||||
return f"视频文件已删除: {video_path}"
|
||||
else:
|
||||
return f"视频文件未找到: {video_path}"
|
||||
1
backend/app/downloaders/common.py
Normal file
1
backend/app/downloaders/common.py
Normal file
@@ -0,0 +1 @@
|
||||
# def download():
|
||||
90
backend/app/downloaders/douyin_downloader.py
Normal file
90
backend/app/downloaders/douyin_downloader.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
from abc import ABC
|
||||
from typing import Union, Optional
|
||||
|
||||
import yt_dlp
|
||||
|
||||
from app.downloaders.base import Downloader, DownloadQuality
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.utils.path_helper import get_data_dir
|
||||
|
||||
|
||||
class DouyinDownloader(Downloader, ABC):
|
||||
def download(
|
||||
self,
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
quality: DownloadQuality = "fast",
|
||||
need_video:Optional[bool]=False
|
||||
) -> AudioDownloadResult:
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
||||
'outtmpl': output_path,
|
||||
'postprocessors': [
|
||||
{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '64',
|
||||
}
|
||||
],
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
video_id = info.get("id")
|
||||
title = info.get("title")
|
||||
duration = info.get("duration", 0)
|
||||
cover_url = info.get("thumbnail")
|
||||
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
|
||||
|
||||
return AudioDownloadResult(
|
||||
file_path=audio_path,
|
||||
title=title,
|
||||
duration=duration,
|
||||
cover_url=cover_url,
|
||||
platform="douyin",
|
||||
video_id=video_id,
|
||||
raw_info={'tags':info.get('tags')}, #全部返回会报错
|
||||
video_path=None # ❗音频下载不包含视频路径
|
||||
)
|
||||
|
||||
def download_video(
|
||||
self,
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
) -> str:
|
||||
"""
|
||||
下载视频,返回视频文件路径
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'worst[ext=mp4]/worst',
|
||||
'outtmpl': output_path,
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
'merge_output_format': 'mp4', # 确保合并成 mp4
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
video_id = info.get("id")
|
||||
video_path = os.path.join(output_dir, f"{video_id}.mp4")
|
||||
|
||||
if not os.path.exists(video_path):
|
||||
raise FileNotFoundError(f"视频文件未找到: {video_path}")
|
||||
|
||||
return video_path
|
||||
95
backend/app/downloaders/youtube_downloader.py
Normal file
95
backend/app/downloaders/youtube_downloader.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
from abc import ABC
|
||||
from typing import Union, Optional
|
||||
|
||||
import yt_dlp
|
||||
|
||||
from app.downloaders.base import Downloader, DownloadQuality
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.utils.path_helper import get_data_dir
|
||||
|
||||
|
||||
class YoutubeDownloader(Downloader, ABC):
|
||||
def __init__(self):
|
||||
|
||||
super().__init__()
|
||||
|
||||
def download(
|
||||
self,
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
quality: DownloadQuality = "fast",
|
||||
need_video:Optional[bool]=False
|
||||
) -> AudioDownloadResult:
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
if not output_dir:
|
||||
output_dir=self.cache_data
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
||||
'outtmpl': output_path,
|
||||
'postprocessors': [
|
||||
{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '64',
|
||||
}
|
||||
],
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
video_id = info.get("id")
|
||||
title = info.get("title")
|
||||
duration = info.get("duration", 0)
|
||||
cover_url = info.get("thumbnail")
|
||||
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
|
||||
|
||||
return AudioDownloadResult(
|
||||
file_path=audio_path,
|
||||
title=title,
|
||||
duration=duration,
|
||||
cover_url=cover_url,
|
||||
platform="youtube",
|
||||
video_id=video_id,
|
||||
raw_info={'tags':info.get('tags')}, #全部返回会报错
|
||||
video_path=None # ❗音频下载不包含视频路径
|
||||
)
|
||||
|
||||
def download_video(
|
||||
self,
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
) -> str:
|
||||
"""
|
||||
下载视频,返回视频文件路径
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'worst[ext=mp4]/worst',
|
||||
'outtmpl': output_path,
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
'merge_output_format': 'mp4', # 确保合并成 mp4
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
video_id = info.get("id")
|
||||
video_path = os.path.join(output_dir, f"{video_id}.mp4")
|
||||
|
||||
if not os.path.exists(video_path):
|
||||
raise FileNotFoundError(f"视频文件未找到: {video_path}")
|
||||
|
||||
return video_path
|
||||
7
backend/app/enmus/note_enums.py
Normal file
7
backend/app/enmus/note_enums.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import enum
|
||||
|
||||
|
||||
class DownloadQuality(str, enum.Enum):
|
||||
fast = "fast"
|
||||
medium = "medium"
|
||||
slow = "slow"
|
||||
0
backend/app/gpt/__init__.py
Normal file
0
backend/app/gpt/__init__.py
Normal file
13
backend/app/gpt/base.py
Normal file
13
backend/app/gpt/base.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from abc import ABC,abstractmethod
|
||||
|
||||
from app.models.gpt_model import GPTSource
|
||||
|
||||
|
||||
class GPT(ABC):
|
||||
def summarize(self, source:GPTSource )->str:
|
||||
'''
|
||||
|
||||
:param source:
|
||||
:return:
|
||||
'''
|
||||
pass
|
||||
59
backend/app/gpt/deepseek_gpt.py
Normal file
59
backend/app/gpt/deepseek_gpt.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from typing import List
|
||||
from app.gpt.base import GPT
|
||||
from openai import OpenAI
|
||||
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT
|
||||
from app.gpt.utils import fix_markdown
|
||||
from app.models.gpt_model import GPTSource
|
||||
from app.models.transcriber_model import TranscriptSegment
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class DeepSeekGPT(GPT):
|
||||
def __init__(self):
|
||||
from os import getenv
|
||||
self.api_key = getenv("DEEP_SEEK_API_KEY")
|
||||
self.base_url = getenv("DEEP_SEEK_API_BASE_URL")
|
||||
self.model=getenv('DEEP_SEEK_MODEL')
|
||||
print(self.model)
|
||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
self.screenshot = False
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15
|
||||
|
||||
def _build_segment_text(self, segments: List[TranscriptSegment]) -> str:
|
||||
return "\n".join(
|
||||
f"{self._format_time(seg.start)} - {seg.text.strip()}"
|
||||
for seg in segments
|
||||
)
|
||||
|
||||
def ensure_segments_type(self, segments) -> List[TranscriptSegment]:
|
||||
return [
|
||||
TranscriptSegment(**seg) if isinstance(seg, dict) else seg
|
||||
for seg in segments
|
||||
]
|
||||
|
||||
def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str):
|
||||
content = BASE_PROMPT.format(
|
||||
video_title=title,
|
||||
segment_text=self._build_segment_text(segments),
|
||||
tags=tags
|
||||
)
|
||||
if self.screenshot:
|
||||
print(":需要截图")
|
||||
content += SCREENSHOT
|
||||
print(content)
|
||||
return [{"role": "user", "content": content + AI_SUM}]
|
||||
|
||||
def summarize(self, source: GPTSource) -> str:
|
||||
self.screenshot = source.screenshot
|
||||
source.segment = self.ensure_segments_type(source.segment)
|
||||
messages = self.create_messages(source.segment, source.title,source.tags)
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=0.7
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
|
||||
65
backend/app/gpt/openai_gpt.py
Normal file
65
backend/app/gpt/openai_gpt.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from typing import List
|
||||
from app.gpt.base import GPT
|
||||
from openai import OpenAI
|
||||
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK
|
||||
from app.gpt.utils import fix_markdown
|
||||
from app.models.gpt_model import GPTSource
|
||||
from app.models.transcriber_model import TranscriptSegment
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class OpenaiGPT(GPT):
|
||||
def __init__(self):
|
||||
from os import getenv
|
||||
self.api_key = getenv("OPENAI_API_KEY")
|
||||
self.base_url = getenv("OPENAI_API_BASE_URL")
|
||||
self.model=getenv('OPENAI_MODEL')
|
||||
print(self.model)
|
||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
self.screenshot = False
|
||||
self.link=False
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15
|
||||
|
||||
def _build_segment_text(self, segments: List[TranscriptSegment]) -> str:
|
||||
return "\n".join(
|
||||
f"{self._format_time(seg.start)} - {seg.text.strip()}"
|
||||
for seg in segments
|
||||
)
|
||||
|
||||
def ensure_segments_type(self, segments) -> List[TranscriptSegment]:
|
||||
return [
|
||||
TranscriptSegment(**seg) if isinstance(seg, dict) else seg
|
||||
for seg in segments
|
||||
]
|
||||
|
||||
def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str):
|
||||
content = BASE_PROMPT.format(
|
||||
video_title=title,
|
||||
segment_text=self._build_segment_text(segments),
|
||||
tags=tags
|
||||
)
|
||||
if self.screenshot:
|
||||
print(":需要截图")
|
||||
content += SCREENSHOT
|
||||
if self.link:
|
||||
print(":需要链接")
|
||||
content += LINK
|
||||
|
||||
print(content)
|
||||
return [{"role": "user", "content": content + AI_SUM}]
|
||||
|
||||
def summarize(self, source: GPTSource) -> str:
|
||||
self.screenshot = source.screenshot
|
||||
self.link = source.link
|
||||
source.segment = self.ensure_segments_type(source.segment)
|
||||
messages = self.create_messages(source.segment, source.title,source.tags)
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=0.7
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
|
||||
56
backend/app/gpt/prompt.py
Normal file
56
backend/app/gpt/prompt.py
Normal file
@@ -0,0 +1,56 @@
|
||||
BASE_PROMPT = '''
|
||||
You are a professional note-taking assistant who excels at summarizing video transcripts into clear, structured, and information-rich notes.
|
||||
|
||||
🎯 Language Requirement:
|
||||
- The notes must be written in **Chinese**.
|
||||
- Proper nouns, technical terms, brand names, and personal names should remain in **English** where appropriate.
|
||||
|
||||
📌 Video Title:
|
||||
{video_title}
|
||||
|
||||
📎 Video Tags:
|
||||
{tags}
|
||||
|
||||
📝 Your Task:
|
||||
Based on the segmented transcript below, generate structured notes in standard **Markdown format**, and follow these principles:
|
||||
|
||||
1. **Complete information**: Record as much relevant detail as possible to ensure comprehensive coverage.
|
||||
2. **Clear structure**: Organize content with logical sectioning. Use appropriate heading levels (`##`, `###`) to summarize key points in each section.
|
||||
3. **Concise wording**: Use accurate, clear, and professional Chinese expressions.
|
||||
4. **Remove irrelevant content**: Omit advertisements, filler words, casual greetings, and off-topic remarks.
|
||||
5. **Keep critical details**: Preserve important facts, examples, conclusions, and recommendations.
|
||||
6. **Readable layout**: Use bullet points where needed, and keep paragraphs reasonably short to enhance readability.
|
||||
7. **Table of Contents**: Generate a table of contents at the top based on the `##` level headings.
|
||||
|
||||
|
||||
⚠️ Output Instructions:
|
||||
- Only return the final **Markdown content**.
|
||||
- Do **not** wrap the output in code blocks like ```` ```markdown ```` or ```` ``` ````.
|
||||
|
||||
|
||||
🎬 Transcript Segments (Format: Start Time - Text):
|
||||
|
||||
---
|
||||
{segment_text}
|
||||
---
|
||||
'''
|
||||
|
||||
LINK='''
|
||||
9. **Add time markers**: THIS IS IMPORTANT For every main heading (`##`), append the starting time of that segment using the format ,start with *Content ,eg: `*Content-[mm:ss]`.
|
||||
|
||||
|
||||
'''
|
||||
AI_SUM='''
|
||||
|
||||
🧠 Final Touch:
|
||||
At the end of the notes, add a professional **AI Summary** in Chinese – a brief conclusion summarizing the whole video.
|
||||
|
||||
|
||||
|
||||
'''
|
||||
|
||||
SCREENSHOT='''
|
||||
8. **Screenshot placeholders**: If a section involves **visual demonstrations, code walkthroughs, UI interactions**, or any content where visuals aid understanding, insert a screenshot cue at the end of that section:
|
||||
- Format: `*Screenshot-[mm:ss]`
|
||||
- Only use it when truly helpful.
|
||||
'''
|
||||
59
backend/app/gpt/qwen_gpt.py
Normal file
59
backend/app/gpt/qwen_gpt.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from typing import List
|
||||
from app.gpt.base import GPT
|
||||
from openai import OpenAI
|
||||
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT
|
||||
from app.gpt.utils import fix_markdown
|
||||
from app.models.gpt_model import GPTSource
|
||||
from app.models.transcriber_model import TranscriptSegment
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class QwenGPT(GPT):
|
||||
def __init__(self):
|
||||
from os import getenv
|
||||
self.api_key = getenv("QWEN_API_KEY")
|
||||
self.base_url = getenv("QWEN_API_BASE_URL")
|
||||
self.model=getenv('QWEN_MODEL')
|
||||
print(self.model)
|
||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
self.screenshot = False
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15
|
||||
|
||||
def _build_segment_text(self, segments: List[TranscriptSegment]) -> str:
|
||||
return "\n".join(
|
||||
f"{self._format_time(seg.start)} - {seg.text.strip()}"
|
||||
for seg in segments
|
||||
)
|
||||
|
||||
def ensure_segments_type(self, segments) -> List[TranscriptSegment]:
|
||||
return [
|
||||
TranscriptSegment(**seg) if isinstance(seg, dict) else seg
|
||||
for seg in segments
|
||||
]
|
||||
|
||||
def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str):
|
||||
content = BASE_PROMPT.format(
|
||||
video_title=title,
|
||||
segment_text=self._build_segment_text(segments),
|
||||
tags=tags
|
||||
)
|
||||
if self.screenshot:
|
||||
print(":需要截图")
|
||||
content += SCREENSHOT
|
||||
print(content)
|
||||
return [{"role": "user", "content": content + AI_SUM}]
|
||||
|
||||
def summarize(self, source: GPTSource) -> str:
|
||||
self.screenshot = source.screenshot
|
||||
source.segment = self.ensure_segments_type(source.segment)
|
||||
messages = self.create_messages(source.segment, source.title,source.tags)
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=0.7
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
|
||||
0
backend/app/gpt/tools.py
Normal file
0
backend/app/gpt/tools.py
Normal file
4
backend/app/gpt/utils.py
Normal file
4
backend/app/gpt/utils.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import codecs
|
||||
|
||||
def fix_markdown(markdown: str) -> str:
|
||||
return codecs.decode(markdown, 'unicode_escape')
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
15
backend/app/models/audio_model.py
Normal file
15
backend/app/models/audio_model.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioDownloadResult:
|
||||
file_path: str # 本地音频路径
|
||||
title: str # 视频标题
|
||||
duration: float # 视频时长(秒)
|
||||
cover_url: Optional[str] # 视频封面图
|
||||
platform: str # 平台,如 "bilibili"
|
||||
video_id: str # 唯一视频ID
|
||||
raw_info: dict # yt-dlp 的原始 info 字典
|
||||
video_path: Optional[str] = None # ✅ 新增字段:可选视频文件路径
|
||||
|
||||
14
backend/app/models/gpt_model.py
Normal file
14
backend/app/models/gpt_model.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from app.models.transcriber_model import TranscriptSegment
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPTSource:
|
||||
segment: Union[List[TranscriptSegment], List]
|
||||
title: str
|
||||
tags:str
|
||||
screenshot: Optional[bool] = False
|
||||
link: Optional[bool] = False
|
||||
|
||||
12
backend/app/models/notes_model.py
Normal file
12
backend/app/models/notes_model.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from app.models.audio_model import AudioDownloadResult
|
||||
from app.models.transcriber_model import TranscriptResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class NoteResult:
|
||||
markdown: str # GPT 总结的 Markdown 内容
|
||||
transcript: TranscriptResult # Whisper 转写结果
|
||||
audio_meta: AudioDownloadResult # 音频下载的元信息(title、duration、封面等)
|
||||
16
backend/app/models/transcriber_model.py
Normal file
16
backend/app/models/transcriber_model.py
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
@dataclass
|
||||
class TranscriptSegment:
|
||||
start: float # 开始时间(秒)
|
||||
end: float # 结束时间(秒)
|
||||
text: str # 该段文字
|
||||
|
||||
@dataclass
|
||||
class TranscriptResult:
|
||||
language: Optional[str] # 检测语言(如 "zh"、"en")
|
||||
full_text: str # 完整合并后的文本(用于摘要)
|
||||
segments: List[TranscriptSegment] # 分段结构,适合前端显示时间轴字幕等
|
||||
raw: Optional[dict] = None # 原始响应数据,便于调试或平台特性处理
|
||||
0
backend/app/models/video_record.py
Normal file
0
backend/app/models/video_record.py
Normal file
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
139
backend/app/routers/note.py
Normal file
139
backend/app/routers/note.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# app/routers/note.py
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel, validator
|
||||
from dataclasses import asdict
|
||||
|
||||
from app.db.video_task_dao import get_task_by_video
|
||||
from app.enmus.note_enums import DownloadQuality
|
||||
from app.services.note import NoteGenerator
|
||||
from app.utils.response import ResponseWrapper as R
|
||||
from app.utils.url_parser import extract_video_id
|
||||
from app.validators.video_url_validator import is_supported_video_url
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import httpx
|
||||
|
||||
# from app.services.downloader import download_raw_audio
|
||||
# from app.services.whisperer import transcribe_audio
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class RecordRequest(BaseModel):
|
||||
video_id: str
|
||||
platform: str
|
||||
|
||||
|
||||
class VideoRequest(BaseModel):
|
||||
video_url: str
|
||||
platform: str
|
||||
quality: DownloadQuality
|
||||
screenshot: Optional[bool] = False
|
||||
link: Optional[bool] = False
|
||||
|
||||
@validator("video_url")
|
||||
def validate_supported_url(cls, v):
|
||||
url = str(v)
|
||||
# 支持平台校验
|
||||
if not is_supported_video_url(url):
|
||||
raise ValueError("暂不支持该视频平台或链接格式无效")
|
||||
return v
|
||||
|
||||
|
||||
NOTE_OUTPUT_DIR = "note_results"
|
||||
|
||||
|
||||
def save_note_to_file(task_id: str, note):
|
||||
os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True)
|
||||
with open(os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(asdict(note), f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality, link: bool = False,screenshot: bool = False):
|
||||
try:
|
||||
note = NoteGenerator().generate(
|
||||
video_url=video_url,
|
||||
platform=platform,
|
||||
quality=quality,
|
||||
task_id=task_id,
|
||||
link=link,
|
||||
screenshot=screenshot
|
||||
)
|
||||
print('Note 结果',note)
|
||||
save_note_to_file(task_id, note)
|
||||
except Exception as e:
|
||||
save_note_to_file(task_id, {"error": str(e)})
|
||||
|
||||
|
||||
@router.post('/delete_task')
|
||||
def delete_task(data:RecordRequest):
|
||||
try:
|
||||
|
||||
NoteGenerator().delete_note(video_id=data.video_id,platform=data.platform)
|
||||
return R.success(msg='删除成功')
|
||||
except Exception as e:
|
||||
return R.error(msg=e)
|
||||
|
||||
|
||||
@router.post("/generate_note")
|
||||
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
|
||||
try:
|
||||
|
||||
video_id = extract_video_id(data.video_url, data.platform)
|
||||
if not video_id:
|
||||
raise HTTPException(status_code=400, detail="无法提取视频 ID")
|
||||
existing = get_task_by_video(video_id, data.platform)
|
||||
if existing:
|
||||
return R.error(
|
||||
msg='笔记已生成,请勿重复发起',
|
||||
|
||||
)
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality,data.link ,data.screenshot)
|
||||
return R.success({"task_id": task_id})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/task_status/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json")
|
||||
if not os.path.exists(path):
|
||||
return R.success({"status": "PENDING"})
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = json.load(f)
|
||||
|
||||
if "error" in content:
|
||||
return R.error(content["error"], code=500)
|
||||
content['id'] = task_id
|
||||
return R.success({
|
||||
"status": "SUCCESS",
|
||||
"result": content
|
||||
})
|
||||
|
||||
|
||||
@router.get("/image_proxy")
|
||||
async def image_proxy(request: Request, url: str):
|
||||
headers = {
|
||||
"Referer": "https://www.bilibili.com/", # 模拟B站来源
|
||||
"User-Agent": request.headers.get("User-Agent", ""),
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="图片获取失败")
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "image/jpeg")
|
||||
return StreamingResponse(resp.aiter_bytes(), media_type=content_type)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
184
backend/app/services/note.py
Normal file
184
backend/app/services/note.py
Normal file
@@ -0,0 +1,184 @@
|
||||
import os
|
||||
from typing import Union
|
||||
|
||||
from pydantic import HttpUrl
|
||||
|
||||
from app.db.video_task_dao import insert_video_task, delete_task_by_video
|
||||
from app.downloaders.base import Downloader
|
||||
from app.downloaders.bilibili_downloader import BilibiliDownloader
|
||||
from app.downloaders.douyin_downloader import DouyinDownloader
|
||||
from app.downloaders.youtube_downloader import YoutubeDownloader
|
||||
from app.gpt.base import GPT
|
||||
from app.gpt.deepseek_gpt import DeepSeekGPT
|
||||
from app.gpt.openai_gpt import OpenaiGPT
|
||||
from app.gpt.qwen_gpt import QwenGPT
|
||||
from app.models.gpt_model import GPTSource
|
||||
from app.models.notes_model import NoteResult
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.enmus.note_enums import DownloadQuality
|
||||
from app.models.transcriber_model import TranscriptResult
|
||||
from app.transcriber.base import Transcriber
|
||||
from app.transcriber.transcriber_provider import get_transcriber
|
||||
from app.transcriber.whisper import WhisperTranscriber
|
||||
import re
|
||||
|
||||
from app.utils.note_helper import replace_content_markers
|
||||
from app.utils.video_helper import generate_screenshot
|
||||
|
||||
# from app.services.whisperer import transcribe_audio
|
||||
# from app.services.gpt import summarize_text
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
BACKEND_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
output_dir = os.getenv('OUT_DIR')
|
||||
image_base_url = os.getenv('IMAGE_BASE_URL')
|
||||
print(output_dir)
|
||||
|
||||
|
||||
class NoteGenerator:
|
||||
def __init__(self):
|
||||
self.model_size: str = 'base'
|
||||
self.device: Union[str, None] = None
|
||||
self.transcriber_type = 'fast-whisper'
|
||||
self.transcriber = self.get_transcriber()
|
||||
# TODO 需要更换为可调节
|
||||
|
||||
self.provider = os.getenv('MODEl_PROVIDER','openai')
|
||||
self.video_path = None
|
||||
|
||||
def get_gpt(self) -> GPT:
|
||||
if self.provider == 'openai':
|
||||
return OpenaiGPT()
|
||||
elif self.provider == 'deepSeek':
|
||||
return DeepSeekGPT()
|
||||
elif self.provider == 'qwen':
|
||||
return QwenGPT()
|
||||
else:
|
||||
raise ValueError(f"不支持的AI提供商:{self.provider}")
|
||||
|
||||
def get_downloader(self, platform: str) -> Downloader:
|
||||
if platform == "bilibili":
|
||||
return BilibiliDownloader()
|
||||
elif platform == "youtube":
|
||||
return YoutubeDownloader()
|
||||
elif platform == 'douyin':
|
||||
return DouyinDownloader()
|
||||
else:
|
||||
raise ValueError(f"不支持的平台:{platform}")
|
||||
|
||||
def get_transcriber(self) -> Transcriber:
|
||||
'''
|
||||
|
||||
:param transcriber: 选择的转义器
|
||||
:return:
|
||||
'''
|
||||
if self.transcriber_type == 'fast-whisper':
|
||||
return get_transcriber()
|
||||
else:
|
||||
raise ValueError(f"不支持的转义器:{self.transcriber}")
|
||||
|
||||
def save_meta(self, video_id, platform, task_id):
|
||||
insert_video_task(video_id=video_id, platform=platform, task_id=task_id)
|
||||
|
||||
def insert_screenshots_into_markdown(self, markdown: str, video_path: str, image_base_url: str,
|
||||
output_dir: str) -> str:
|
||||
"""
|
||||
扫描 markdown 中的 *Screenshot-xx:xx,生成截图并插入 markdown 图片
|
||||
:param markdown:
|
||||
:param image_base_url: 最终返回给前端的路径前缀(如 /static/screenshots)
|
||||
"""
|
||||
matches = self.extract_screenshot_timestamps(markdown)
|
||||
new_markdown = markdown
|
||||
|
||||
for idx, (marker, ts) in enumerate(matches):
|
||||
image_path = generate_screenshot(video_path, output_dir, ts, idx)
|
||||
image_relative_path = os.path.join(image_base_url, os.path.basename(image_path)).replace("\\", "/")
|
||||
image_url = f"{BACKEND_BASE_URL.rstrip('/')}/{image_relative_path.lstrip('/')}"
|
||||
replacement = f""
|
||||
new_markdown = new_markdown.replace(marker, replacement, 1)
|
||||
|
||||
return new_markdown
|
||||
|
||||
@staticmethod
|
||||
def delete_note(video_id: str, platform: str):
|
||||
return delete_task_by_video(video_id, platform)
|
||||
|
||||
import re
|
||||
|
||||
def extract_screenshot_timestamps(self, markdown: str) -> list[tuple[str, int]]:
|
||||
"""
|
||||
从 Markdown 中提取 Screenshot 时间标记(如 *Screenshot-03:39 或 Screenshot-[03:39]),
|
||||
并返回匹配文本和对应时间戳(秒)
|
||||
"""
|
||||
pattern = r"(?:\*Screenshot-(\d{2}):(\d{2})|Screenshot-\[(\d{2}):(\d{2})\])"
|
||||
matches = list(re.finditer(pattern, markdown))
|
||||
results = []
|
||||
for match in matches:
|
||||
mm = match.group(1) or match.group(3)
|
||||
ss = match.group(2) or match.group(4)
|
||||
total_seconds = int(mm) * 60 + int(ss)
|
||||
results.append((match.group(0), total_seconds))
|
||||
return results
|
||||
|
||||
def generate(
|
||||
self,
|
||||
|
||||
video_url: Union[str, HttpUrl],
|
||||
platform: str,
|
||||
quality: DownloadQuality = DownloadQuality.medium,
|
||||
task_id: Union[str, None] = None,
|
||||
link: bool = False,
|
||||
screenshot: bool = False,
|
||||
path: Union[str, None] = None
|
||||
|
||||
) -> NoteResult:
|
||||
|
||||
# 1. 选择下载器
|
||||
downloader = self.get_downloader(platform)
|
||||
gpt = self.get_gpt()
|
||||
|
||||
if screenshot:
|
||||
video_path = downloader.download_video(video_url)
|
||||
self.video_path = video_path
|
||||
print(video_path)
|
||||
|
||||
# 2. 下载音频
|
||||
audio: AudioDownloadResult = downloader.download(
|
||||
video_url=video_url,
|
||||
quality=quality,
|
||||
output_dir=path,
|
||||
need_video=screenshot
|
||||
|
||||
)
|
||||
|
||||
# 3. Whisper 转写
|
||||
transcript: TranscriptResult = self.transcriber.transcript(file_path=audio.file_path)
|
||||
|
||||
# 4. GPT 总结
|
||||
source = GPTSource(
|
||||
title=audio.title,
|
||||
segment=transcript.segments,
|
||||
tags=audio.raw_info.get('tags'),
|
||||
screenshot=screenshot,
|
||||
link=link
|
||||
)
|
||||
markdown: str = gpt.summarize(source)
|
||||
print("markdown结果", markdown)
|
||||
|
||||
markdown = replace_content_markers(markdown=markdown, video_id=audio.video_id, platform=platform)
|
||||
if self.video_path:
|
||||
markdown = self.insert_screenshots_into_markdown(markdown, self.video_path, image_base_url, output_dir)
|
||||
self.save_meta(video_id=audio.video_id, platform=platform, task_id=task_id)
|
||||
# 5. 返回结构体
|
||||
return NoteResult(
|
||||
markdown=markdown,
|
||||
transcript=transcript,
|
||||
audio_meta=audio
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
note = NoteGenerator()
|
||||
print(note.audio_meta)
|
||||
0
backend/app/transcriber/__init__.py
Normal file
0
backend/app/transcriber/__init__.py
Normal file
14
backend/app/transcriber/base.py
Normal file
14
backend/app/transcriber/base.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.models.transcriber_model import TranscriptResult
|
||||
|
||||
|
||||
class Transcriber(ABC):
|
||||
@abstractmethod
|
||||
def transcript(self,file_path:str)->TranscriptResult:
|
||||
'''
|
||||
|
||||
:param file_path:音频路径
|
||||
:return: 返回一个 TranscriptResult 类
|
||||
'''
|
||||
pass
|
||||
11
backend/app/transcriber/transcriber_provider.py
Normal file
11
backend/app/transcriber/transcriber_provider.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from app.transcriber.whisper import WhisperTranscriber
|
||||
print('实例化transcriber')
|
||||
# TODO:后面需要加入逻辑选择
|
||||
_transcriber = None
|
||||
|
||||
def get_transcriber(model_size="base", device="cuda"):
|
||||
global _transcriber
|
||||
if _transcriber is None:
|
||||
print('加载_transcriber')
|
||||
_transcriber = WhisperTranscriber(model_size=model_size, device=device)
|
||||
return _transcriber
|
||||
92
backend/app/transcriber/whisper.py
Normal file
92
backend/app/transcriber/whisper.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
from app.decorators.timeit import timeit
|
||||
from app.models.transcriber_model import TranscriptSegment, TranscriptResult
|
||||
from app.transcriber.base import Transcriber
|
||||
from app.utils.env_checker import is_cuda_available, is_torch_installed
|
||||
from app.utils.path_helper import get_model_dir
|
||||
|
||||
'''
|
||||
Size of the model to use (tiny, tiny.en, base, base.en, small, small.en, distil-small.en, medium, medium.en, distil-medium.en, large-v1, large-v2, large-v3, large, distil-large-v2, distil-large-v3, large-v3-turbo, or turbo
|
||||
'''
|
||||
|
||||
|
||||
class WhisperTranscriber(Transcriber):
|
||||
# TODO:修改为可配置
|
||||
def __init__(
|
||||
self,
|
||||
model_size: str = "base",
|
||||
device: str = 'cpu',
|
||||
compute_type: str = None,
|
||||
cpu_threads: int = 1,
|
||||
):
|
||||
if device == 'cpu' or device is None:
|
||||
self.device = 'cpu'
|
||||
else:
|
||||
self.device = "cuda" if self.is_cuda() else "cpu"
|
||||
if device == 'cuda' and self.device == 'cpu':
|
||||
print('没有 cuda 使用 cpu进行计算')
|
||||
|
||||
self.compute_type = compute_type or ("float16" if self.device == "cuda" else "int8")
|
||||
|
||||
model_path = get_model_dir("whisper")
|
||||
self.model = WhisperModel(
|
||||
model_size,
|
||||
device=self.device,
|
||||
# compute_type="int8", # 或 "float16"
|
||||
cpu_threads=cpu_threads,
|
||||
download_root=model_path
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_torch_installed() -> bool:
|
||||
try:
|
||||
import torch
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_cuda() -> bool:
|
||||
try:
|
||||
if is_cuda_available():
|
||||
print("✅ CUDA 可用,使用 GPU")
|
||||
return True
|
||||
elif is_torch_installed():
|
||||
print("⚠️ 只装了 torch,但没有 CUDA,用 CPU")
|
||||
return False
|
||||
else:
|
||||
print("❌ 还没有安装 torch,请先安装")
|
||||
return False
|
||||
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
@timeit
|
||||
def transcript(self, file_path: str) -> TranscriptResult:
|
||||
|
||||
segments_raw, info = self.model.transcribe(file_path)
|
||||
|
||||
segments = []
|
||||
full_text = ""
|
||||
|
||||
for seg in segments_raw:
|
||||
text = seg.text.strip()
|
||||
full_text += text + " "
|
||||
segments.append(TranscriptSegment(
|
||||
start=seg.start,
|
||||
end=seg.end,
|
||||
text=text
|
||||
))
|
||||
|
||||
return TranscriptResult(
|
||||
language=info.language,
|
||||
full_text=full_text.strip(),
|
||||
segments=segments,
|
||||
raw=info
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(WhisperTranscriber(cpu_threads=8).transcript(
|
||||
'''D:\\data_backup_from_ssd\\02_个人项目\\11_BiliNote\\backend\\data\\BV1vcZ5YQE9X.mp3'''))
|
||||
12
backend/app/utils/env_checker.py
Normal file
12
backend/app/utils/env_checker.py
Normal file
@@ -0,0 +1,12 @@
|
||||
def is_cuda_available() -> bool:
|
||||
try:
|
||||
import torch
|
||||
return torch.cuda.is_available()
|
||||
except ImportError:
|
||||
return False
|
||||
def is_torch_installed() -> bool:
|
||||
try:
|
||||
import torch
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
32
backend/app/utils/note_helper.py
Normal file
32
backend/app/utils/note_helper.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import re
|
||||
|
||||
|
||||
import re
|
||||
|
||||
import re
|
||||
|
||||
def replace_content_markers(markdown: str, video_id: str, platform: str = 'bilibili') -> str:
|
||||
"""
|
||||
替换 *Content-04:16*、Content-04:16 或 Content-[04:16] 为超链接,跳转到对应平台视频的时间位置
|
||||
"""
|
||||
# 匹配三种形式:*Content-04:16*、Content-04:16、Content-[04:16]
|
||||
pattern = r"(?:\*?)Content-(?:\[(\d{2}):(\d{2})\]|(\d{2}):(\d{2}))"
|
||||
|
||||
def replacer(match):
|
||||
mm = match.group(1) or match.group(3)
|
||||
ss = match.group(2) or match.group(4)
|
||||
total_seconds = int(mm) * 60 + int(ss)
|
||||
|
||||
if platform == 'bilibili':
|
||||
url = f"https://www.bilibili.com/video/{video_id}?t={total_seconds}"
|
||||
elif platform == 'youtube':
|
||||
url = f"https://www.youtube.com/watch?v={video_id}&t={total_seconds}s"
|
||||
elif platform == 'douyin':
|
||||
url = f"https://www.douyin.com/video/{video_id}"
|
||||
return f"[原片 @ {mm}:{ss}]({url})"
|
||||
else:
|
||||
return f"({mm}:{ss})"
|
||||
|
||||
return f"[原片 @ {mm}:{ss}]({url})"
|
||||
|
||||
return re.sub(pattern, replacer, markdown)
|
||||
18
backend/app/utils/path_helper.py
Normal file
18
backend/app/utils/path_helper.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))
|
||||
|
||||
def get_data_dir():
|
||||
data_path = os.path.join(PROJECT_ROOT, "data")
|
||||
os.makedirs(data_path, exist_ok=True)
|
||||
return data_path
|
||||
|
||||
def get_model_dir(subdir: str = "whisper") -> str:
|
||||
base = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../models"))
|
||||
path = os.path.join(base, subdir)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(get_data_dir())
|
||||
18
backend/app/utils/response.py
Normal file
18
backend/app/utils/response.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from app.utils.status_code import StatusCode
|
||||
|
||||
class ResponseWrapper:
|
||||
@staticmethod
|
||||
def success(data=None, msg="success", code=StatusCode.SUCCESS):
|
||||
return {
|
||||
"code": int(code),
|
||||
"msg": msg,
|
||||
"data": data
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def error(msg="error", code=StatusCode.FAIL, data=None):
|
||||
return {
|
||||
"code": int(code),
|
||||
"msg": msg,
|
||||
"data": data
|
||||
}
|
||||
12
backend/app/utils/status_code.py
Normal file
12
backend/app/utils/status_code.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from enum import IntEnum
|
||||
|
||||
class StatusCode(IntEnum):
|
||||
SUCCESS = 0
|
||||
FAIL = 1
|
||||
|
||||
DOWNLOAD_ERROR = 1001
|
||||
TRANSCRIBE_ERROR = 1002
|
||||
GENERATE_ERROR = 1003
|
||||
|
||||
INVALID_URL = 2001
|
||||
PARAM_ERROR = 2002
|
||||
28
backend/app/utils/url_parser.py
Normal file
28
backend/app/utils/url_parser.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def extract_video_id(url: str, platform: str) -> Optional[str]:
|
||||
"""
|
||||
从视频链接中提取视频 ID
|
||||
|
||||
:param url: 视频链接
|
||||
:param platform: 平台名(bilibili / youtube / douyin)
|
||||
:return: 提取到的视频 ID 或 None
|
||||
"""
|
||||
if platform == "bilibili":
|
||||
# 匹配 BV号(如 BV1vc411b7Wa)
|
||||
match = re.search(r"BV([0-9A-Za-z]+)", url)
|
||||
return f"BV{match.group(1)}" if match else None
|
||||
|
||||
elif platform == "youtube":
|
||||
# 匹配 v=xxxxx 或 youtu.be/xxxxx,ID 长度通常为 11
|
||||
match = re.search(r"(?:v=|youtu\.be/)([0-9A-Za-z_-]{11})", url)
|
||||
return match.group(1) if match else None
|
||||
|
||||
elif platform == "douyin":
|
||||
# 匹配 douyin.com/video/1234567890123456789
|
||||
match = re.search(r"/video/(\d+)", url)
|
||||
return match.group(1) if match else None
|
||||
|
||||
return None
|
||||
26
backend/app/utils/video_helper.py
Normal file
26
backend/app/utils/video_helper.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import subprocess
|
||||
import os
|
||||
import uuid
|
||||
|
||||
|
||||
def generate_screenshot(video_path: str, output_dir: str, timestamp: int, index: int) -> str:
|
||||
"""
|
||||
使用 ffmpeg 生成截图,返回生成图片路径
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
ids=str(uuid.uuid4())
|
||||
output_path = os.path.join(output_dir, f"screenshot_{str(index)+ids}.jpg")
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-ss", str(timestamp),
|
||||
"-i", video_path,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "2", # 图像质量
|
||||
output_path,
|
||||
"-y" # 覆盖
|
||||
]
|
||||
|
||||
subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return output_path
|
||||
|
||||
0
backend/app/validators/__init__.py
Normal file
0
backend/app/validators/__init__.py
Normal file
24
backend/app/validators/video_url_validator.py
Normal file
24
backend/app/validators/video_url_validator.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pydantic import AnyUrl, validator, BaseModel
|
||||
import re
|
||||
|
||||
SUPPORTED_PLATFORMS = {
|
||||
"bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+",
|
||||
"youtube": r"(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/)[\w\-]+",
|
||||
"douyin": r"(https?://)?(www\.)?douyin\.com/video/\d+",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def is_supported_video_url(url: str) -> bool:
|
||||
return any(re.match(pattern, url) for pattern in SUPPORTED_PLATFORMS.values())
|
||||
|
||||
|
||||
class VideoRequest(BaseModel):
|
||||
url: AnyUrl
|
||||
platform: str
|
||||
|
||||
@validator("url")
|
||||
def validate_video_url(cls, v):
|
||||
if not is_supported_video_url(str(v)):
|
||||
raise ValueError("暂不支持该视频平台或链接格式无效")
|
||||
return v
|
||||
34
backend/ffmpeg_helper.py
Normal file
34
backend/ffmpeg_helper.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
import subprocess
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
def check_ffmpeg_exists() -> bool:
|
||||
"""
|
||||
检查 ffmpeg 是否可用。优先使用 FFMPEG_BIN_PATH 环境变量指定的路径。
|
||||
"""
|
||||
ffmpeg_bin_path = os.getenv("FFMPEG_BIN_PATH")
|
||||
print(f"FFMPEG_BIN_PATH: {ffmpeg_bin_path}")
|
||||
|
||||
if ffmpeg_bin_path and os.path.isdir(ffmpeg_bin_path):
|
||||
os.environ["PATH"] = ffmpeg_bin_path + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
try:
|
||||
subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||
return True
|
||||
except (FileNotFoundError, OSError, subprocess.CalledProcessError):
|
||||
return False
|
||||
|
||||
|
||||
def ensure_ffmpeg_or_raise():
|
||||
"""
|
||||
校验 ffmpeg 是否可用,否则抛出异常并提示安装方式。
|
||||
"""
|
||||
if not check_ffmpeg_exists():
|
||||
raise EnvironmentError(
|
||||
"❌ 未检测到 ffmpeg,请先安装后再使用本功能。\n"
|
||||
"👉 下载地址:https://ffmpeg.org/download.html\n"
|
||||
"🪟 Windows 推荐:https://www.gyan.dev/ffmpeg/builds/\n"
|
||||
"💡 如果你已安装,请将其路径写入 `.env` 文件,例如:\n"
|
||||
"FFMPEG_BIN_PATH=/your/custom/ffmpeg/bin"
|
||||
)
|
||||
44
backend/main.py
Normal file
44
backend/main.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app import create_app
|
||||
from app.db.video_task_dao import init_video_task_table
|
||||
from app.transcriber.transcriber_provider import get_transcriber
|
||||
from ffmpeg_helper import ensure_ffmpeg_or_raise
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 读取 .env 中的路径
|
||||
static_path = os.getenv('STATIC', '/static')
|
||||
out_dir = os.getenv('OUT_DIR', './static/screenshots')
|
||||
|
||||
# 自动创建本地目录(static 和 static/screenshots)
|
||||
static_dir = "static"
|
||||
if not os.path.exists(static_dir):
|
||||
os.makedirs(static_dir)
|
||||
|
||||
if not os.path.exists(out_dir):
|
||||
os.makedirs(out_dir)
|
||||
|
||||
app = create_app()
|
||||
app.mount(static_path, StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
@app.on_event("startup")
|
||||
def check_env():
|
||||
ensure_ffmpeg_or_raise()
|
||||
@app.on_event("startup")
|
||||
async def load_model_on_startup():
|
||||
get_transcriber()
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
init_video_task_table()
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.getenv("BACKEND_PORT", 8000))
|
||||
|
||||
host = os.getenv("BACKEND_HOST", "0.0.0.0")
|
||||
uvicorn.run("main:app", host=host, port=port, reload=True)
|
||||
BIN
backend/requirements.txt
Normal file
BIN
backend/requirements.txt
Normal file
Binary file not shown.
Reference in New Issue
Block a user