Skip to content
Merged
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
2 changes: 1 addition & 1 deletion BillNote_frontend/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "BiliNote",
"version": "1.8.1",
"version": "2.0.0",
"identifier": "com.jefferyhuang.bilinote",
"build": {
"frontendDist": "../dist",
Expand Down
2 changes: 1 addition & 1 deletion BillNote_frontend/src/pages/SettingPage/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function AboutPage() {
height={50}
className="rounded-lg"
/>
<h1 className="text-4xl font-bold">BiliNote v1.8.1</h1>
<h1 className="text-4xl font-bold">BiliNote v2.0.0</h1>
</div>
<p className="text-muted-foreground mb-6 text-xl italic">
AI 视频笔记生成工具 让 AI 为你的视频做笔记
Expand Down
79 changes: 64 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
<p align="center">
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
</p>
<h1 align="center" > BiliNote v1.8.1</h1>
<h1 align="center" > BiliNote v2.0.0</h1>
</div>

<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>

<p align="center">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" />
<img src="https://img.shields.io/badge/frontend-react-blue" />
<img src="https://img.shields.io/badge/frontend-react%2019-blue" />
<img src="https://img.shields.io/badge/backend-fastapi-green" />
<img src="https://img.shields.io/badge/GPT-openai%20%7C%20deepseek%20%7C%20qwen-ff69b4" />
<img src="https://img.shields.io/badge/docker-compose-blue" />
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" />
<img src="https://img.shields.io/badge/status-active-success" />
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
</p>
Expand All @@ -22,30 +22,48 @@

## ✨ 项目简介

BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。

## 📝 使用文档
详细文档可以查看[这里](https://docs.bilinote.app/)

## 体验地址
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
## 📦 Windows 打包版
本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.1.1)进行下载。**注意一定要在没有中文路径的环境下运行。**

## 📦 桌面版下载
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。

> Windows 用户请注意:一定要在没有中文路径的环境下运行。

## 🔧 功能特性

- 支持多平台:Bilibili、YouTube、本地视频、抖音(后续会加入更多平台)
- 支持多平台:Bilibili、YouTube、本地视频、抖音、快手
- 支持返回笔记格式选择
- 支持笔记风格选择
- 支持多模态视频理解
- 支持多版本记录保留
- 支持自行配置 GPT 大模型
- 本地模型音频转写(支持 Fast-Whisper)
- 支持自行配置 GPT 大模型(OpenAI、DeepSeek、Qwen 等)
- 本地模型音频转写(支持 Fast-Whisper、MLX-Whisper、Groq、BCut
- GPT 大模型总结视频内容
- 自动生成结构化 Markdown 笔记
- 可选插入截图(自动截取)
- 可选内容跳转链接(关联原视频)
- 任务记录与历史回看
- 基于 RAG 的笔记内容 AI 问答(支持 Function Calling)
- 笔记顶部视频封面 Banner 展示
- 工作区和生成历史面板支持折叠/展开

### v2.0.0 新增

- 基于 RAG 的笔记内容 AI 问答功能,支持半屏/全屏模式
- AI 问答支持 Function Calling,模型可主动查询原文数据
- RAG 索引支持视频元信息(标题、作者、简介、标签等)
- AI 回复支持 Markdown 渲染
- 笔记顶部新增视频封面 Banner
- 工作区和生成历史面板支持折叠/展开
- 笔记开头添加来源链接功能
- YouTube 字幕优先获取,有字幕时跳过音频下载
- 性能优化与转写器配置改进

## 📸 截图预览
![screenshot](./doc/image1.png)
Expand All @@ -56,35 +74,63 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y

## 🚀 快速开始

### 1. 克隆仓库
### 方式一:Docker 部署(推荐)

确保已安装 Docker,直接拉取预构建镜像运行:

```bash
docker pull ghcr.io/jefferyhcool/bilinote:latest

docker run -d -p 80:80 \
-v bilinote-data:/app/backend/data \
--name bilinote \
ghcr.io/jefferyhcool/bilinote:latest
```

访问:`http://localhost`

也可以使用 docker-compose 本地构建:

```bash
# 标准部署
docker-compose up -d

# GPU 加速部署(需要 NVIDIA GPU)
docker-compose -f docker-compose.gpu.yml up -d
```

### 方式二:源码部署

#### 1. 克隆仓库

```bash
git clone https://github.com/JefferyHcool/BiliNote.git
cd BiliNote
mv .env.example .env
```

### 2. 启动后端(FastAPI)
#### 2. 启动后端(FastAPI)

```bash
cd backend
pip install -r requirements.txt
python main.py
```

### 3. 启动前端(Vite + React)
#### 3. 启动前端(Vite + React)

```bash
cd BillNote_frontend
pnpm install
pnpm dev
```

访问:`http://localhost:5173`
访问:`http://localhost:3015`

## ⚙️ 依赖说明

### 🎬 FFmpeg
本项目依赖 ffmpeg 用于音频处理与转码,必须安装
本项目依赖 ffmpeg 用于音频处理与转码,源码部署时必须安装
```bash
# Mac (brew)
brew install ffmpeg
Expand All @@ -96,6 +142,8 @@ sudo apt install ffmpeg
# 请从官网下载安装:https://ffmpeg.org/download.html
```
> ⚠️ 若系统无法识别 ffmpeg,请将其加入系统环境变量 PATH
>
> Docker 部署已内置 FFmpeg,无需额外安装。

### 🚀 CUDA 加速(可选)
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
Expand Down Expand Up @@ -134,9 +182,10 @@ docker-compose -f docker-compose.gpu.yml up -d
- [x] 支持抖音及快手等视频平台
- [x] 支持前端设置切换 AI 模型切换、语音转文字模型
- [x] AI 摘要风格自定义(学术风、口语风、重点提取等)
- [ ] 笔记导出为 PDF / Word / Notion
- [x] 加入更多模型支持
- [x] 加入更多音频转文本模型支持
- [x] 基于 RAG 的笔记内容 AI 问答
- [ ] 笔记导出为 PDF / Word / Notion

### Contact and Join-联系和加入社区
年会恢复更新以后放出最新社区地址
Expand Down
3 changes: 2 additions & 1 deletion backend/app/downloaders/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def __init__(self):

@abstractmethod
def download(self, video_url: str, output_dir: str = None,
quality: DownloadQuality = "fast", need_video: Optional[bool] = False) -> AudioDownloadResult:
quality: DownloadQuality = "fast", need_video: Optional[bool] = False,
skip_download: bool = False) -> AudioDownloadResult:
'''

:param need_video:
Expand Down
134 changes: 21 additions & 113 deletions backend/app/downloaders/youtube_downloader.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import os
import json
import logging
from abc import ABC
from typing import Union, Optional, List

import yt_dlp

from app.downloaders.base import Downloader, DownloadQuality
from app.downloaders.youtube_subtitle import YouTubeSubtitleFetcher
from app.models.notes_model import AudioDownloadResult
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
from app.models.transcriber_model import TranscriptResult
from app.utils.path_helper import get_data_dir
from app.utils.url_parser import extract_video_id

Expand All @@ -25,12 +25,13 @@ def download(
video_url: str,
output_dir: Union[str, None] = None,
quality: DownloadQuality = "fast",
need_video:Optional[bool]=False
need_video: Optional[bool] = False,
skip_download: bool = False,
) -> AudioDownloadResult:
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir=self.cache_data
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)

output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
Expand All @@ -42,15 +43,17 @@ def download(
'quiet': False,
}

if skip_download:
ydl_opts['skip_download'] = True

with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
info = ydl.extract_info(video_url, download=not skip_download)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
ext = info.get("ext", "m4a") # 兜底用 m4a
ext = info.get("ext", "m4a")
audio_path = os.path.join(output_dir, f"{video_id}.{ext}")
print('os.path.join(output_dir, f"{video_id}.{ext}")',os.path.join(output_dir, f"{video_id}.{ext}"))

return AudioDownloadResult(
file_path=audio_path,
Expand All @@ -59,8 +62,8 @@ def download(
cover_url=cover_url,
platform="youtube",
video_id=video_id,
raw_info={'tags':info.get('tags')}, #全部返回会报错
video_path=None # ❗音频下载不包含视频路径
raw_info={'tags': info.get('tags')},
video_path=None,
)

def download_video(
Expand Down Expand Up @@ -101,115 +104,20 @@ def download_video(
def download_subtitles(self, video_url: str, output_dir: str = None,
langs: List[str] = None) -> Optional[TranscriptResult]:
"""
尝试获取YouTube视频字幕(优先人工字幕,其次自动生成)
通过 YouTube InnerTube API 直接获取字幕(优先人工字幕,其次自动生成)。
比 yt_dlp 方式更轻量,无需写临时文件到磁盘。

:param video_url: 视频链接
:param output_dir: 输出路径
:param output_dir: 未使用(保留接口兼容)
:param langs: 优先语言列表
:return: TranscriptResult 或 None
"""
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)

if langs is None:
langs = ['zh-Hans', 'zh', 'zh-CN', 'zh-TW', 'en', 'en-US']
langs = ['zh-Hans', 'zh', 'zh-CN', 'zh-TW', 'en', 'en-US', 'ja']

video_id = extract_video_id(video_url, "youtube")

ydl_opts = {
'writesubtitles': True,
'writeautomaticsub': True,
'subtitleslangs': langs,
'subtitlesformat': 'json3',
'skip_download': True,
'outtmpl': os.path.join(output_dir, f'{video_id}.%(ext)s'),
'quiet': True,
}

try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)

# 查找下载的字幕文件
subtitles = info.get('requested_subtitles') or {}
if not subtitles:
logger.info(f"YouTube视频 {video_id} 没有可用字幕")
return None

# 按优先级查找字幕文件
subtitle_file = None
detected_lang = None
for lang in langs:
if lang in subtitles:
subtitle_file = os.path.join(output_dir, f"{video_id}.{lang}.json3")
detected_lang = lang
break

# 如果按优先级没找到,取第一个可用的
if not subtitle_file:
for lang, sub_info in subtitles.items():
subtitle_file = os.path.join(output_dir, f"{video_id}.{lang}.json3")
detected_lang = lang
break

if not subtitle_file or not os.path.exists(subtitle_file):
logger.info(f"字幕文件不存在: {subtitle_file}")
return None

# 解析字幕文件
return self._parse_json3_subtitle(subtitle_file, detected_lang)

except Exception as e:
logger.warning(f"获取YouTube字幕失败: {e}")
return None

def _parse_json3_subtitle(self, subtitle_file: str, language: str) -> Optional[TranscriptResult]:
"""
解析 json3 格式字幕文件

:param subtitle_file: 字幕文件路径
:param language: 语言代码
:return: TranscriptResult
"""
try:
with open(subtitle_file, 'r', encoding='utf-8') as f:
data = json.load(f)

segments = []
events = data.get('events', [])

for event in events:
# json3 格式中时间单位是毫秒
start_ms = event.get('tStartMs', 0)
duration_ms = event.get('dDurationMs', 0)

# 提取文本
segs = event.get('segs', [])
text = ''.join(seg.get('utf8', '') for seg in segs).strip()

if text: # 只添加非空文本
segments.append(TranscriptSegment(
start=start_ms / 1000.0,
end=(start_ms + duration_ms) / 1000.0,
text=text
))

if not segments:
return None

full_text = ' '.join(seg.text for seg in segments)

logger.info(f"成功解析YouTube字幕,共 {len(segments)} 段")
return TranscriptResult(
language=language,
full_text=full_text,
segments=segments,
raw={'source': 'youtube_subtitle', 'file': subtitle_file}
)

except Exception as e:
logger.warning(f"解析字幕文件失败: {e}")
return None
fetcher = YouTubeSubtitleFetcher()
print(
f"尝试获取字幕,video_id={video_id}, langs={langs}"
)
return fetcher.fetch_subtitles(video_id, langs)
Loading
Loading