Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4a26561d | ||
|
|
10c1d1f3a8 | ||
|
|
66bcf53d01 | ||
|
|
8ab4b7d693 | ||
|
|
ce2f097d32 | ||
|
|
f7575cd327 | ||
|
|
8634c6a211 | ||
|
|
b070013efc | ||
|
|
d2d9112f6c | ||
|
|
9fea18f2de | ||
|
|
74480f91ce | ||
|
|
b2e13b631f | ||
|
|
001d995c8f | ||
|
|
8cb2acea88 | ||
|
|
7c0d57d84e | ||
|
|
8cb875f449 | ||
|
|
e6bbe65723 | ||
|
|
f4a71a2476 | ||
|
|
47b9362b0a | ||
|
|
c1aad0806e | ||
|
|
4ccc90f9fb | ||
|
|
7dc63440e6 | ||
|
|
4094e8b80d | ||
|
|
e27cbaf715 | ||
|
|
1f39b27d79 | ||
|
|
f45891fd95 | ||
|
|
18fe644715 | ||
|
|
40cde8c69a | ||
|
|
4b0af47906 | ||
|
|
9365b3c8cd | ||
|
|
4b9f015ea7 | ||
|
|
c42d4a084e | ||
|
|
5bb3feb05b | ||
|
|
05f776ed8b | ||
|
|
9cec809485 | ||
|
|
429f909152 | ||
|
|
084dd23df1 | ||
|
|
e55afdd739 | ||
|
|
72128a132b | ||
|
|
92ca2cddad | ||
|
|
3db0d1dfe5 | ||
|
|
57907323e6 | ||
|
|
dbdca44c5f | ||
|
|
fe1dd2201f | ||
|
|
e0ae194cc3 | ||
|
|
6fc5700457 | ||
|
|
c4fdcf86d4 | ||
|
|
3088500c8d | ||
|
|
861f3a3624 | ||
|
|
c55783e4d9 | ||
|
|
955e284d41 | ||
|
|
fc4c47427e | ||
|
|
e2d7563faa | ||
|
|
27d69f7f8d | ||
|
|
a77bb5af44 | ||
|
|
00286261a4 | ||
|
|
0b898dccaa | ||
|
|
a1d9ac4e68 | ||
|
|
4150939e23 | ||
|
|
8f84b7f063 | ||
|
|
04b245ac64 | ||
|
|
12f7e62957 | ||
|
|
9600d310c7 | ||
|
|
dec5a2472a | ||
|
|
13eb7c6ea2 | ||
|
|
2356cfa10a | ||
|
|
3bfaefb3b0 | ||
|
|
78b8c25d96 | ||
|
|
c1d2ff2b96 | ||
|
|
24aee9446a | ||
|
|
2fb094ec31 | ||
|
|
53897c66ee | ||
|
|
ca4e266ae6 | ||
|
|
6612a1e16f | ||
|
|
55ceb65dfb | ||
|
|
6cad3d6afb |
5
.github/workflows/main.yml
vendored
@@ -59,11 +59,6 @@ jobs:
|
||||
if: matrix.platform == 'windows-latest' && matrix.features == 'cuda'
|
||||
uses: Jimver/cuda-toolkit@v0.2.24
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./src-tauri -> target"
|
||||
|
||||
- name: Setup ffmpeg
|
||||
if: matrix.platform == 'windows-latest'
|
||||
working-directory: ./
|
||||
|
||||
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
/target/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
10
README.md
@@ -4,23 +4,27 @@
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
[](https://deepwiki.com/Xinrea/bili-shadowreplay)
|
||||
|
||||
BiliBili ShadowReplay 是一个缓存直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
|
||||
|
||||
目前仅支持 B 站和抖音平台的直播。
|
||||
|
||||

|
||||
[](https://www.star-history.com/#Xinrea/bili-shadowreplay&Date)
|
||||
|
||||
## 安装和使用
|
||||
|
||||

|
||||
|
||||
前往网站查看说明:[BiliBili ShadowReplay](https://bsr.xinrea.cn/)
|
||||
|
||||
## 参与开发
|
||||
|
||||
[Contributing](.github/CONTRIBUTING.md)
|
||||
可以通过 [DeepWiki](https://deepwiki.com/Xinrea/bili-shadowreplay) 了解本项目。
|
||||
|
||||
贡献指南:[Contributing](.github/CONTRIBUTING.md)
|
||||
|
||||
## 赞助
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
import { withMermaid } from "vitepress-plugin-mermaid";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
export default withMermaid({
|
||||
title: "BiliBili ShadowReplay",
|
||||
description: "直播录制/实时回放/剪辑/投稿工具",
|
||||
themeConfig: {
|
||||
@@ -18,21 +19,54 @@ export default defineConfig({
|
||||
{
|
||||
text: "开始使用",
|
||||
items: [
|
||||
{ text: "安装准备", link: "/getting-started/installation" },
|
||||
{ text: "配置使用", link: "/getting-started/configuration" },
|
||||
{ text: "FFmpeg 配置", link: "/getting-started/ffmpeg" },
|
||||
{
|
||||
text: "安装准备",
|
||||
items: [
|
||||
{
|
||||
text: "桌面端安装",
|
||||
link: "/getting-started/installation/desktop",
|
||||
},
|
||||
{
|
||||
text: "Docker 安装",
|
||||
link: "/getting-started/installation/docker",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "配置使用",
|
||||
items: [
|
||||
{ text: "账号配置", link: "/getting-started/config/account" },
|
||||
{ text: "FFmpeg 配置", link: "/getting-started/config/ffmpeg" },
|
||||
{ text: "Whisper 配置", link: "/getting-started/config/whisper" },
|
||||
{ text: "LLM 配置", link: "/getting-started/config/llm" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "说明文档",
|
||||
items: [
|
||||
{ text: "功能说明", link: "/usage/features" },
|
||||
{
|
||||
text: "功能说明",
|
||||
items: [
|
||||
{ text: "工作流程", link: "/usage/features/workflow" },
|
||||
{ text: "直播间管理", link: "/usage/features/room" },
|
||||
{ text: "切片功能", link: "/usage/features/clip" },
|
||||
{ text: "字幕功能", link: "/usage/features/subtitle" },
|
||||
{ text: "弹幕功能", link: "/usage/features/danmaku" },
|
||||
],
|
||||
},
|
||||
{ text: "常见问题", link: "/usage/faq" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "开发文档",
|
||||
items: [{ text: "架构设计", link: "/develop/architecture" }],
|
||||
items: [
|
||||
{
|
||||
text: "DeepWiki",
|
||||
link: "https://deepwiki.com/Xinrea/bili-shadowreplay",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# 架构设计
|
||||
@@ -1,27 +1,12 @@
|
||||
# 配置使用
|
||||
|
||||
## 账号配置
|
||||
# 账号配置
|
||||
|
||||
要添加直播间,至少需要配置一个同平台的账号。在账号页面,你可以通过添加账号按钮添加一个账号。
|
||||
|
||||
- B 站账号:目前支持扫码登录和 Cookie 手动配置两种方式,推荐使用扫码登录
|
||||
- 抖音账号:目前仅支持 Cookie 手动配置登陆
|
||||
|
||||
### 抖音账号配置
|
||||
## 抖音账号配置
|
||||
|
||||
首先确保已经登录抖音,然后打开[个人主页](https://www.douyin.com/user/self),右键单击网页,在菜单中选择 `检查(Inspect)`,打开开发者工具,切换到 `网络(Network)` 选项卡,然后刷新网页,此时能在列表中找到 `self` 请求(一般是列表中第一个),单击该请求,查看`请求标头`,在 `请求标头` 中找到 `Cookie`,复制该字段的值,粘贴到配置页面的 `Cookie` 输入框中,要注意复制完全。
|
||||
|
||||

|
||||
|
||||
## FFmpeg 配置
|
||||
|
||||
如果想要使用切片生成和压制功能,请确保 FFmpeg 已正确配置;除了 Windows 平台打包自带 FFfmpeg 以外,其他平台需要手动安装 FFfmpeg,请参考 [FFfmpeg 配置](/getting-started/ffmpeg)。
|
||||
|
||||
## Whisper 模型配置
|
||||
|
||||
要使用 AI 字幕识别功能,需要在设置页面配置 Whisper 模型路径,模型文件可以从网络上下载,例如:
|
||||
|
||||
- [Whisper.cpp(国内镜像,内容较旧)](https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/files)
|
||||
- [Whisper.cpp](https://huggingface.co/ggerganov/whisper.cpp/tree/main)
|
||||
|
||||
可以跟据自己的需求选择不同的模型,要注意带有 `en` 的模型是英文模型,其他模型为多语言模型。
|
||||
9
docs/getting-started/config/llm.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# LLM 配置
|
||||
|
||||

|
||||
|
||||
助手页面的 AI Agent 助手功能需要配置大模型,目前仅支持配置 OpenAI 协议兼容的大模型服务。
|
||||
|
||||
本软件并不提供大模型服务,请自行选择服务提供商。要注意,使用 AI Agent 助手需要消耗比普通对话更多的 Token,请确保有足够的 Token 余额。
|
||||
|
||||
此外,AI Agent 的功能需要大模型支持 Function Calling 功能,否则无法正常调用工具。
|
||||
35
docs/getting-started/config/whisper.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Whisper 配置
|
||||
|
||||
要使用 AI 字幕识别功能,需要在设置页面配置 Whisper。目前可以选择使用本地运行 Whisper 模型,或是使用在线的 Whisper 服务(通常需要付费获取 API Key)。
|
||||
|
||||
> [!NOTE]
|
||||
> 其实有许多更好的中文字幕识别解决方案,但是这类服务通常需要将文件上传到对象存储后异步处理,考虑到实现的复杂度,选择了使用本地运行 Whisper 模型或是使用在线的 Whisper 服务,在请求返回时能够直接获取字幕生成结果。
|
||||
|
||||
## 本地运行 Whisper 模型
|
||||
|
||||

|
||||
|
||||
如果需要使用本地运行 Whisper 模型进行字幕生成,需要下载 Whisper.cpp 模型,并在设置中指定模型路径。模型文件可以从网络上下载,例如:
|
||||
|
||||
- [Whisper.cpp(国内镜像,内容较旧)](https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/files)
|
||||
- [Whisper.cpp](https://huggingface.co/ggerganov/whisper.cpp/tree/main)
|
||||
|
||||
可以跟据自己的需求选择不同的模型,要注意带有 `en` 的模型是英文模型,其他模型为多语言模型。
|
||||
|
||||
模型文件的大小通常意味着其在运行时资源占用的大小,因此请根据电脑配置选择合适的模型。此外,GPU 版本与 CPU 版本在字幕生成速度上存在**巨大差异**,因此推荐使用 GPU 版本进行本地处理(目前仅支持 Nvidia GPU)。
|
||||
|
||||
## 使用在线 Whisper 服务
|
||||
|
||||

|
||||
|
||||
如果需要使用在线的 Whisper 服务进行字幕生成,可以在设置中切换为在线 Whisper,并配置好 API Key。提供 Whisper 服务的平台并非只有 OpenAI 一家,许多云服务平台也提供 Whisper 服务。
|
||||
|
||||
## 字幕识别质量的调优
|
||||
|
||||
目前在设置中支持设置 Whisper 语言和 Whisper 提示词,这些设置对于本地和在线的 Whisper 服务都有效。
|
||||
|
||||
通常情况下,`auto` 语言选项能够自动识别语音语言,并生成相应语言的字幕。如果需要生成其他语言的字幕,或是生成的字幕语言不匹配,可以手动配置指定的语言。根据 OpenAI 官方文档中对于 `language` 参数的描述,目前支持的语言包括
|
||||
|
||||
Afrikaans, Arabic, Armenian, Azerbaijani, Belarusian, Bosnian, Bulgarian, Catalan, Chinese, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, Galician, German, Greek, Hebrew, Hindi, Hungarian, Icelandic, Indonesian, Italian, Japanese, Kannada, Kazakh, Korean, Latvian, Lithuanian, Macedonian, Malay, Marathi, Maori, Nepali, Norwegian, Persian, Polish, Portuguese, Romanian, Russian, Serbian, Slovak, Slovenian, Spanish, Swahili, Swedish, Tagalog, Tamil, Thai, Turkish, Ukrainian, Urdu, Vietnamese, and Welsh.
|
||||
|
||||
提示词可以优化生成的字幕的风格(也会一定程度上影响质量),要注意,Whisper 无法理解复杂的提示词,你可以在提示词中使用一些简单的描述,让其在选择词汇时使用偏向于提示词所描述的领域相关的词汇,以避免出现毫不相干领域的词汇;或是让它在标点符号的使用上参照提示词的风格。
|
||||
@@ -1,66 +0,0 @@
|
||||
# 安装准备
|
||||
|
||||
## 桌面端安装
|
||||
|
||||
桌面端目前提供了 Windows、Linux 和 MacOS 三个平台的安装包。
|
||||
|
||||
安装包分为两个版本,普通版和 debug 版,普通版适合大部分用户使用,debug 版包含了更多的调试信息,适合开发者使用;由于程序会对账号等敏感信息进行管理,请从信任的来源进行下载;所有版本均可在 [GitHub Releases](https://github.com/Xinrea/bili-shadowreplay/releases) 页面下载安装。
|
||||
|
||||
### Windows
|
||||
|
||||
由于程序内置 Whisper 字幕识别模型支持,Windows 版本分为两种:
|
||||
|
||||
- **普通版本**:内置了 Whisper GPU 加速,字幕识别较快,体积较大,只支持 Nvidia 显卡
|
||||
- **CPU 版本**: 使用 CPU 进行字幕识别推理,速度较慢
|
||||
|
||||
请根据自己的显卡情况选择合适的版本进行下载。
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 版本目前仅支持使用 CPU 推理,且测试较少,可能存在一些问题,遇到问题请及时反馈。
|
||||
|
||||
### MacOS
|
||||
|
||||
MacOS 版本内置 Metal GPU 加速;安装后首次运行,会提示无法打开从网络下载的软件,请在设置-隐私与安全性下,选择仍然打开以允许程序运行。
|
||||
|
||||
## Docker 部署
|
||||
|
||||
BiliBili ShadowReplay 提供了服务端部署的能力,提供 Web 控制界面,可以用于在服务器等无图形界面环境下部署使用。
|
||||
|
||||
### 镜像获取
|
||||
|
||||
```bash
|
||||
# 拉取最新版本
|
||||
docker pull ghcr.io/xinrea/bili-shadowreplay:latest
|
||||
# 拉取指定版本
|
||||
docker pull ghcr.io/xinrea/bili-shadowreplay:2.5.0
|
||||
# 速度太慢?从镜像源拉取
|
||||
docker pull ghcr.nju.edu.cn/xinrea/bili-shadowreplay:latest
|
||||
```
|
||||
|
||||
### 镜像使用
|
||||
|
||||
使用方法:
|
||||
|
||||
```bash
|
||||
sudo docker run -it -d\
|
||||
-p 3000:3000 \
|
||||
-v $DATA_DIR:/app/data \
|
||||
-v $CACHE_DIR:/app/cache \
|
||||
-v $OUTPUT_DIR:/app/output \
|
||||
-v $WHISPER_MODEL:/app/whisper_model.bin \
|
||||
--name bili-shadowreplay \
|
||||
ghcr.io/xinrea/bili-shadowreplay:latest
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `$DATA_DIR`:为数据目录,对应于桌面版的数据目录,
|
||||
|
||||
Windows 下位于 `C:\Users\{用户名}\AppData\Roaming\cn.vjoi.bilishadowreplay`;
|
||||
|
||||
MacOS 下位于 `/Users/{user}/Library/Application Support/cn.vjoi.bilishadowreplay`
|
||||
|
||||
- `$CACHE_DIR`:为缓存目录,对应于桌面版的缓存目录;
|
||||
- `$OUTPUT_DIR`:为输出目录,对应于桌面版的输出目录;
|
||||
- `$WHISPER_MODEL`:为 Whisper 模型文件路径,对应于桌面版的 Whisper 模型文件路径。
|
||||
22
docs/getting-started/installation/desktop.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 桌面端安装
|
||||
|
||||
桌面端目前提供了 Windows、Linux 和 MacOS 三个平台的安装包。
|
||||
|
||||
安装包分为两个版本,普通版和 debug 版,普通版适合大部分用户使用,debug 版包含了更多的调试信息,适合开发者使用;由于程序会对账号等敏感信息进行管理,请从信任的来源进行下载;所有版本均可在 [GitHub Releases](https://github.com/Xinrea/bili-shadowreplay/releases) 页面下载安装。
|
||||
|
||||
## Windows
|
||||
|
||||
由于程序内置 Whisper 字幕识别模型支持,Windows 版本分为两种:
|
||||
|
||||
- **普通版本**:内置了 Whisper GPU 加速,字幕识别较快,体积较大,只支持 Nvidia 显卡
|
||||
- **CPU 版本**: 使用 CPU 进行字幕识别推理,速度较慢
|
||||
|
||||
请根据自己的显卡情况选择合适的版本进行下载。
|
||||
|
||||
## Linux
|
||||
|
||||
Linux 版本目前仅支持使用 CPU 推理,且测试较少,可能存在一些问题,遇到问题请及时反馈。
|
||||
|
||||
## MacOS
|
||||
|
||||
MacOS 版本内置 Metal GPU 加速;安装后首次运行,会提示无法打开从网络下载的软件,请在设置-隐私与安全性下,选择仍然打开以允许程序运行。
|
||||
41
docs/getting-started/installation/docker.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Docker 部署
|
||||
|
||||
BiliBili ShadowReplay 提供了服务端部署的能力,提供 Web 控制界面,可以用于在服务器等无图形界面环境下部署使用。
|
||||
|
||||
## 镜像获取
|
||||
|
||||
```bash
|
||||
# 拉取最新版本
|
||||
docker pull ghcr.io/xinrea/bili-shadowreplay:latest
|
||||
# 拉取指定版本
|
||||
docker pull ghcr.io/xinrea/bili-shadowreplay:2.5.0
|
||||
# 速度太慢?从镜像源拉取
|
||||
docker pull ghcr.nju.edu.cn/xinrea/bili-shadowreplay:latest
|
||||
```
|
||||
|
||||
## 镜像使用
|
||||
|
||||
使用方法:
|
||||
|
||||
```bash
|
||||
sudo docker run -it -d\
|
||||
-p 3000:3000 \
|
||||
-v $DATA_DIR:/app/data \
|
||||
-v $CACHE_DIR:/app/cache \
|
||||
-v $OUTPUT_DIR:/app/output \
|
||||
-v $WHISPER_MODEL:/app/whisper_model.bin \
|
||||
--name bili-shadowreplay \
|
||||
ghcr.io/xinrea/bili-shadowreplay:latest
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `$DATA_DIR`:为数据目录,对应于桌面版的数据目录,
|
||||
|
||||
Windows 下位于 `C:\Users\{用户名}\AppData\Roaming\cn.vjoi.bilishadowreplay`;
|
||||
|
||||
MacOS 下位于 `/Users/{user}/Library/Application Support/cn.vjoi.bilishadowreplay`
|
||||
|
||||
- `$CACHE_DIR`:为缓存目录,对应于桌面版的缓存目录;
|
||||
- `$OUTPUT_DIR`:为输出目录,对应于桌面版的输出目录;
|
||||
- `$WHISPER_MODEL`:为 Whisper 模型文件路径,对应于桌面版的 Whisper 模型文件路径。
|
||||
@@ -11,10 +11,10 @@ hero:
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 开始使用
|
||||
link: /getting-started/installation
|
||||
link: /getting-started/installation/desktop
|
||||
- theme: alt
|
||||
text: 说明文档
|
||||
link: /usage/features
|
||||
link: /usage/features/workflow
|
||||
|
||||
features:
|
||||
- icon: 📹
|
||||
@@ -38,9 +38,9 @@ features:
|
||||
- icon: 🔍
|
||||
title: 云端部署
|
||||
details: 支持 Docker 部署,提供 Web 控制界面
|
||||
- icon: 📦
|
||||
title: 多平台支持
|
||||
details: 桌面端支持 Windows/Linux/macOS
|
||||
- icon: 🤖
|
||||
title: AI Agent 支持
|
||||
details: 支持 AI 助手管理录播,分析直播内容,生成切片
|
||||
---
|
||||
|
||||
## 总览
|
||||
@@ -63,7 +63,7 @@ features:
|
||||
|
||||
## 封面编辑
|
||||
|
||||

|
||||

|
||||
|
||||
## 设置
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 555 KiB After Width: | Height: | Size: 195 KiB |
BIN
docs/public/images/ai_agent.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 434 KiB |
BIN
docs/public/images/clip_manage.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
docs/public/images/clip_preview.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
docs/public/images/cover_edit.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.1 MiB |
BIN
docs/public/images/model_config.png
Normal file
|
After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 949 KiB |
|
Before Width: | Height: | Size: 622 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 721 KiB After Width: | Height: | Size: 372 KiB |
BIN
docs/public/images/tasks.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
docs/public/images/whisper_local.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
docs/public/images/whisper_online.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
docs/public/images/whole_clip.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/public/videos/deeplinking.mp4
Normal file
BIN
docs/public/videos/room_remove.mp4
Normal file
@@ -0,0 +1,31 @@
|
||||
# 常见问题
|
||||
|
||||
## 一、在哪里反馈问题?
|
||||
|
||||
你可以前往 [Github Issues](https://github.com/Xinrea/bili-shadowreplay/issues/new?template=bug_report.md) 提交问题,或是加入[反馈交流群](https://qm.qq.com/q/v4lrE6gyum)。
|
||||
|
||||
1. 在提交问题前,请先阅读其它常见问题,确保你的问题已有解答;
|
||||
2. 其次,请确保你的程序已更新到最新版本;
|
||||
3. 最后,你应准备好提供你的程序日志文件,以便更好地定位问题。
|
||||
|
||||
## 二、在哪里查看日志?
|
||||
|
||||
在主窗口的设置页面,提供了一键打开日志目录所在位置的按钮。当你打开日志目录所在位置后,进入 `logs` 目录,找到后缀名为 `log` 的文件,这便是你需要提供给开发者的日志文件。
|
||||
|
||||
## 三、无法预览直播或是生成切片
|
||||
|
||||
如果你是 macOS 或 Linux 用户,请确保你已安装了 `ffmpeg` 和 `ffprobe` 工具;如果不知道如何安装,请参考 [FFmpeg 配置](/getting-started/config/ffmpeg)。
|
||||
|
||||
如果你是 Windows 用户,程序目录下应当自带了 `ffmpeg` 和 `ffprobe` 工具,如果无法预览直播或是生成切片,请向开发者反馈。
|
||||
|
||||
## 四、添加 B 站直播间出现 -352 错误
|
||||
|
||||
`-352` 错误是由 B 站风控机制导致的,如果你添加了大量的 B 站直播间进行录制,可以在设置页面调整直播间状态的检查间隔,尽量避免风控;如果你在直播间数量较少的情况下出现该错误,请向开发者反馈。
|
||||
|
||||
## 五、录播为什么都是碎片文件?
|
||||
|
||||
缓存目录下的录播文件并非用于直接播放或是投稿,而是用于直播流的预览与实时回放。如果你需要录播文件用于投稿,请打开对应录播的预览界面,使用快捷键创建选区,生成所需范围的切片,切片文件为常规的 mp4 文件,位于你所设置的切片目录下。
|
||||
|
||||
如果你将 BSR 作为单纯的录播软件使用,在设置中可以开启`整场录播生成`,这样在直播结束后,BSR 会自动生成整场录播的切片。
|
||||
|
||||

|
||||
|
||||
1
docs/usage/features/clip.md
Normal file
@@ -0,0 +1 @@
|
||||
# 切片
|
||||
1
docs/usage/features/danmaku.md
Normal file
@@ -0,0 +1 @@
|
||||
# 弹幕
|
||||
38
docs/usage/features/room.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 直播间
|
||||
|
||||
> [!WARNING]
|
||||
> 在添加管理直播间前,请确保账号列表中有对应平台的可用账号。
|
||||
|
||||
## 添加直播间
|
||||
|
||||
### 手动添加直播间
|
||||
|
||||
你可以在 BSR 直播间页面,点击按钮手动添加直播间。你需要选择平台,并输入直播间号。
|
||||
|
||||
直播间号通常是直播间网页地址尾部的遗传数字,例如 `https://live.bilibili.com/123456` 中的 `123456`,或是 `https://live.douyin.com/123456` 中的 `123456`。
|
||||
|
||||
抖音直播间比较特殊,当未开播时,你无法找到直播间的入口,因此你需要当直播间开播时找到直播间网页地址,并记录其直播间号。
|
||||
|
||||
抖音直播间需要输入主播的 sec_uid,你可以在主播主页的 URL 中找到,例如 `https://www.douyin.com/user/MS4wLjABAAAA` 中的 `MS4wLjABAAAA`。
|
||||
|
||||
### 使用 DeepLinking 快速添加直播间
|
||||
|
||||
<video src="/videos/deeplinking.mp4" loop autoplay muted style="border-radius: 10px;"></video>
|
||||
|
||||
在浏览器中观看直播时,替换地址栏中直播间地址中的 `https://` 为 `bsr://` 即可快速唤起 BSR 添加直播间。
|
||||
|
||||
## 启用/禁用直播间
|
||||
|
||||
你可以点击直播间卡片右上角的菜单按钮,选择启用/禁用直播间。
|
||||
|
||||
- 启用后,当直播间开播时,会自动开始录制
|
||||
- 禁用后,当直播间开播时,不会自动开始录制
|
||||
|
||||
## 移除直播间
|
||||
|
||||
> [!CAUTION]
|
||||
> 移除直播间后,该直播间相关的所有录播都会被删除,请谨慎操作。
|
||||
|
||||
你可以点击直播间卡片右上角的菜单按钮,选择移除直播间。
|
||||
|
||||
<video src="/videos/room_remove.mp4" loop autoplay muted style="border-radius: 10px;"></video>
|
||||
1
docs/usage/features/subtitle.md
Normal file
@@ -0,0 +1 @@
|
||||
# 字幕
|
||||
30
docs/usage/features/workflow.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 工作流程
|
||||
|
||||
- 直播间:各个平台的直播间
|
||||
- 录播:直播流的存档,每次录制会自动生成一场录播记录
|
||||
- 切片:从直播流中剪切生成的视频片段
|
||||
- 投稿:将切片上传到各个平台(目前仅支持 Bilibili)
|
||||
|
||||
下图展示了它们之间的关系:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[直播间] -->|录制| B[录播 01]
|
||||
A -->|录制| C[录播 02]
|
||||
A -->|录制| E[录播 N]
|
||||
|
||||
B --> F[直播流预览窗口]
|
||||
|
||||
F -->|区间生成| G[切片 01]
|
||||
F -->|区间生成| H[切片 02]
|
||||
F -->|区间生成| I[切片 N]
|
||||
|
||||
G --> J[切片预览窗口]
|
||||
|
||||
J -->|字幕压制| K[新切片]
|
||||
|
||||
K --> J
|
||||
|
||||
J -->|投稿| L[Bilibili]
|
||||
|
||||
```
|
||||
17
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bili-shadowreplay",
|
||||
"private": true,
|
||||
"version": "2.7.4",
|
||||
"version": "2.11.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,10 +11,16 @@
|
||||
"tauri": "tauri",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"bump": "node scripts/bump.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.4.1",
|
||||
"@langchain/core": "^0.3.64",
|
||||
"@langchain/deepseek": "^0.1.0",
|
||||
"@langchain/langgraph": "^0.3.10",
|
||||
"@langchain/ollama": "^0.2.3",
|
||||
"@tauri-apps/api": "^2.6.2",
|
||||
"@tauri-apps/plugin-deep-link": "~2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
@@ -23,6 +29,7 @@
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"@tauri-apps/plugin-sql": "~2",
|
||||
"lucide-svelte": "^0.479.0",
|
||||
"marked": "^16.1.1",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35,6 +42,7 @@
|
||||
"flowbite": "^2.5.1",
|
||||
"flowbite-svelte": "^0.46.16",
|
||||
"flowbite-svelte-icons": "^1.6.1",
|
||||
"mermaid": "^11.9.0",
|
||||
"postcss": "^8.4.21",
|
||||
"svelte": "^3.54.0",
|
||||
"svelte-check": "^3.0.0",
|
||||
@@ -44,6 +52,7 @@
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^4.0.0",
|
||||
"vitepress": "^1.6.3"
|
||||
"vitepress": "^1.6.3",
|
||||
"vitepress-plugin-mermaid": "^2.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
58
scripts/bump.cjs
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function updatePackageJson(version) {
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
JSON.stringify(packageJson, null, 2) + "\n"
|
||||
);
|
||||
console.log(`✅ Updated package.json version to ${version}`);
|
||||
}
|
||||
|
||||
function updateCargoToml(version) {
|
||||
const cargoTomlPath = path.join(process.cwd(), "src-tauri", "Cargo.toml");
|
||||
let cargoToml = fs.readFileSync(cargoTomlPath, "utf8");
|
||||
|
||||
// Update the version in the [package] section
|
||||
cargoToml = cargoToml.replace(/^version = ".*"$/m, `version = "${version}"`);
|
||||
|
||||
fs.writeFileSync(cargoTomlPath, cargoToml);
|
||||
console.log(`✅ Updated Cargo.toml version to ${version}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("❌ Please provide a version number");
|
||||
console.error("Usage: yarn bump <version>");
|
||||
console.error("Example: yarn bump 3.1.0");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const version = args[0];
|
||||
|
||||
// Validate version format (simple check)
|
||||
if (!/^\d+\.\d+\.\d+/.test(version)) {
|
||||
console.error(
|
||||
"❌ Invalid version format. Please use semantic versioning (e.g., 3.1.0)"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
updatePackageJson(version);
|
||||
updateCargoToml(version);
|
||||
console.log(`🎉 Successfully bumped version to ${version}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Error updating version:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
949
src-tauri/Cargo.lock
generated
@@ -4,7 +4,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "bili-shadowreplay"
|
||||
version = "1.0.0"
|
||||
version = "2.11.4"
|
||||
description = "BiliBili ShadowReplay"
|
||||
authors = ["Xinrea"]
|
||||
license = ""
|
||||
@@ -44,13 +44,14 @@ async-trait = "0.1.87"
|
||||
whisper-rs = "0.14.2"
|
||||
hound = "3.5.1"
|
||||
uuid = { version = "1.4", features = ["v4"] }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
axum = { version = "0.7", features = ["macros", "multipart"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
futures-core = "0.3"
|
||||
futures = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
clap = { version = "4.5.37", features = ["derive"] }
|
||||
url = "2.5.4"
|
||||
srtparse = "0.2.0"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
@@ -70,6 +71,7 @@ gui = [
|
||||
"tauri-utils",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-deep-link",
|
||||
"fix-path-env",
|
||||
"tauri-build",
|
||||
]
|
||||
@@ -82,6 +84,7 @@ optional = true
|
||||
[dependencies.tauri-plugin-single-instance]
|
||||
version = "2"
|
||||
optional = true
|
||||
features = ["deep-link"]
|
||||
|
||||
[dependencies.tauri-plugin-dialog]
|
||||
version = "2"
|
||||
@@ -116,6 +119,10 @@ optional = true
|
||||
version = "2"
|
||||
optional = true
|
||||
|
||||
[dependencies.tauri-plugin-deep-link]
|
||||
version = "2"
|
||||
optional = true
|
||||
|
||||
[dependencies.fix-path-env]
|
||||
git = "https://github.com/tauri-apps/fix-path-env-rs"
|
||||
optional = true
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
"identifier": "migrated",
|
||||
"description": "permissions that were migrated from v1",
|
||||
"local": true,
|
||||
"windows": ["main", "Live*", "Clip*"],
|
||||
"windows": [
|
||||
"main",
|
||||
"Live*",
|
||||
"Clip*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"fs:allow-read-file",
|
||||
@@ -16,7 +20,9 @@
|
||||
"fs:allow-exists",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": ["**"]
|
||||
"allow": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
@@ -65,6 +71,7 @@
|
||||
"shell:default",
|
||||
"sql:default",
|
||||
"os:default",
|
||||
"dialog:default"
|
||||
"dialog:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ urlencoding = "2.1.3"
|
||||
gzip = "0.1.2"
|
||||
hex = "0.4.3"
|
||||
async-trait = "0.1.88"
|
||||
uuid = "1.17.0"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.10"
|
||||
|
||||
41
src-tauri/crates/danmu_stream/examples/bilibili.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use danmu_stream::{danmu_stream::DanmuStream, provider::ProviderType, DanmuMessageType};
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize logging
|
||||
env_logger::init();
|
||||
// Replace these with actual values
|
||||
let room_id = 768756;
|
||||
let cookie = "";
|
||||
let stream = Arc::new(DanmuStream::new(ProviderType::BiliBili, cookie, room_id).await?);
|
||||
|
||||
log::info!("Start to receive danmu messages: {}", cookie);
|
||||
|
||||
let stream_clone = stream.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
log::info!("Waitting for message");
|
||||
if let Ok(Some(msg)) = stream_clone.recv().await {
|
||||
match msg {
|
||||
DanmuMessageType::DanmuMessage(danmu) => {
|
||||
log::info!("Received danmu message: {:?}", danmu.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!("Channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = stream.start().await;
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
stream.stop().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -14,6 +14,7 @@ custom_error! {pub DanmuStreamError
|
||||
InvalidIdentifier {err: String} = "InvalidIdentifier {err}"
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DanmuMessageType {
|
||||
DanmuMessage(DanmuMessage),
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ impl DanmuProvider for BiliDanmu {
|
||||
async fn new(cookie: &str, room_id: u64) -> Result<Self, DanmuStreamError> {
|
||||
// find DedeUserID=<user_id> in cookie str
|
||||
let user_id = BiliDanmu::parse_user_id(cookie)?;
|
||||
let client = ApiClient::new(cookie);
|
||||
// add buvid3 to cookie
|
||||
let cookie = format!("{};buvid3={}", cookie, uuid::Uuid::new_v4());
|
||||
let client = ApiClient::new(&cookie);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
@@ -121,9 +123,11 @@ impl BiliDanmu {
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
) -> Result<(), DanmuStreamError> {
|
||||
let wbi_key = self.get_wbi_key().await?;
|
||||
let danmu_info = self.get_danmu_info(&wbi_key, self.room_id).await?;
|
||||
let real_room = self.get_real_room(&wbi_key, self.room_id).await?;
|
||||
let danmu_info = self.get_danmu_info(&wbi_key, real_room).await?;
|
||||
let ws_hosts = danmu_info.data.host_list.clone();
|
||||
let mut conn = None;
|
||||
log::debug!("ws_hosts: {:?}", ws_hosts);
|
||||
// try to connect to ws_hsots, once success, send the token to the tx
|
||||
for i in ws_hosts {
|
||||
let host = format!("wss://{}/sub", i.host);
|
||||
@@ -149,7 +153,7 @@ impl BiliDanmu {
|
||||
*self.write.write().await = Some(write);
|
||||
|
||||
let json = serde_json::to_string(&WsSend {
|
||||
roomid: self.room_id,
|
||||
roomid: real_room,
|
||||
key: danmu_info.data.token,
|
||||
uid: self.user_id,
|
||||
protover: 3,
|
||||
@@ -209,6 +213,7 @@ impl BiliDanmu {
|
||||
if let Ok(ws) = ws {
|
||||
match ws.match_msg() {
|
||||
Ok(v) => {
|
||||
log::debug!("Received message: {:?}", v);
|
||||
tx.send(v).map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: e.to_string(),
|
||||
})?;
|
||||
@@ -235,7 +240,6 @@ impl BiliDanmu {
|
||||
wbi_key: &str,
|
||||
room_id: u64,
|
||||
) -> Result<DanmuInfo, DanmuStreamError> {
|
||||
let room_id = self.get_real_room(wbi_key, room_id).await?;
|
||||
let params = self
|
||||
.get_sign(
|
||||
wbi_key,
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
provider::{bilibili::dannmu_msg::BiliDanmuMessage, DanmuMessageType},
|
||||
DanmuStreamError, DanmuMessage,
|
||||
DanmuMessage, DanmuStreamError,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -83,7 +83,7 @@ impl WsStreamCtx {
|
||||
fn handle_cmd(&self) -> Option<&str> {
|
||||
// handle DANMU_MSG:4:0:2:2:2:0
|
||||
let cmd = if let Some(c) = self.cmd.as_deref() {
|
||||
if c.starts_with("DANMU_MSG") {
|
||||
if c.starts_with("DM_INTERACTION") {
|
||||
Some("DANMU_MSG")
|
||||
} else {
|
||||
Some(c)
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*","Clip*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default","dialog:default"]}}
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*","Clip*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default","dialog:default","deep-link:default"]}}
|
||||
@@ -37,7 +37,7 @@
|
||||
],
|
||||
"definitions": {
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
@@ -49,7 +49,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3152,6 +3152,12 @@
|
||||
"const": "core:webview:allow-reparent",
|
||||
"markdownDescription": "Enables the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:allow-set-webview-auto-resize",
|
||||
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -3254,6 +3260,12 @@
|
||||
"const": "core:webview:deny-reparent",
|
||||
"markdownDescription": "Denies the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:deny-set-webview-auto-resize",
|
||||
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -4208,6 +4220,60 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`",
|
||||
"type": "string",
|
||||
"const": "deep-link:default",
|
||||
"markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-get-current",
|
||||
"markdownDescription": "Enables the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-get-current",
|
||||
"markdownDescription": "Denies the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
],
|
||||
"definitions": {
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
@@ -49,7 +49,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3152,6 +3152,12 @@
|
||||
"const": "core:webview:allow-reparent",
|
||||
"markdownDescription": "Enables the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:allow-set-webview-auto-resize",
|
||||
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -3254,6 +3260,12 @@
|
||||
"const": "core:webview:deny-reparent",
|
||||
"markdownDescription": "Denies the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:deny-set-webview-auto-resize",
|
||||
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -4208,6 +4220,60 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`",
|
||||
"type": "string",
|
||||
"const": "deep-link:default",
|
||||
"markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-get-current",
|
||||
"markdownDescription": "Enables the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-get-current",
|
||||
"markdownDescription": "Denies the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{recorder::PlatformType, recorder_manager::ClipRangeParams};
|
||||
@@ -33,6 +33,10 @@ pub struct Config {
|
||||
pub status_check_interval: u64,
|
||||
#[serde(skip)]
|
||||
pub config_path: String,
|
||||
#[serde(default = "default_whisper_language")]
|
||||
pub whisper_language: String,
|
||||
#[serde(default = "default_user_agent")]
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
@@ -80,6 +84,14 @@ fn default_status_check_interval() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_whisper_language() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
fn default_user_agent() -> String {
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string()
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(
|
||||
config_path: &PathBuf,
|
||||
@@ -116,6 +128,8 @@ impl Config {
|
||||
auto_generate: default_auto_generate_config(),
|
||||
status_check_interval: default_status_check_interval(),
|
||||
config_path: config_path.to_str().unwrap().into(),
|
||||
whisper_language: default_whisper_language(),
|
||||
user_agent: default_user_agent(),
|
||||
};
|
||||
|
||||
config.save();
|
||||
@@ -142,6 +156,18 @@ impl Config {
|
||||
self.save();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_whisper_language(&mut self, language: &str) {
|
||||
self.whisper_language = language.to_string();
|
||||
self.save();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_user_agent(&mut self, user_agent: &str) {
|
||||
self.user_agent = user_agent.to_string();
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn generate_clip_name(&self, params: &ClipRangeParams) -> PathBuf {
|
||||
let platform = PlatformType::from_str(¶ms.platform).unwrap();
|
||||
|
||||
@@ -157,13 +183,31 @@ impl Config {
|
||||
let format_config = format_config.replace("{platform}", platform.as_str());
|
||||
let format_config = format_config.replace("{room_id}", ¶ms.room_id.to_string());
|
||||
let format_config = format_config.replace("{live_id}", ¶ms.live_id);
|
||||
let format_config = format_config.replace("{x}", ¶ms.x.to_string());
|
||||
let format_config = format_config.replace("{y}", ¶ms.y.to_string());
|
||||
let format_config = format_config.replace(
|
||||
"{x}",
|
||||
¶ms
|
||||
.range
|
||||
.as_ref()
|
||||
.map_or("0".to_string(), |r| r.start.to_string()),
|
||||
);
|
||||
let format_config = format_config.replace(
|
||||
"{y}",
|
||||
¶ms
|
||||
.range
|
||||
.as_ref()
|
||||
.map_or("0".to_string(), |r| r.end.to_string()),
|
||||
);
|
||||
let format_config = format_config.replace(
|
||||
"{created_at}",
|
||||
&Utc::now().format("%Y-%m-%d_%H-%M-%S").to_string(),
|
||||
&Local::now().format("%Y-%m-%d_%H-%M-%S").to_string(),
|
||||
);
|
||||
let format_config = format_config.replace(
|
||||
"{length}",
|
||||
¶ms
|
||||
.range
|
||||
.as_ref()
|
||||
.map_or("0".to_string(), |r| r.duration().to_string()),
|
||||
);
|
||||
let format_config = format_config.replace("{length}", &(params.y - params.x).to_string());
|
||||
|
||||
let output = self.output.clone();
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ use rand::Rng;
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct AccountRow {
|
||||
pub platform: String,
|
||||
pub uid: u64,
|
||||
pub uid: u64, // Keep for Bilibili compatibility
|
||||
pub id_str: Option<String>, // New field for string IDs like Douyin sec_uid
|
||||
pub name: String,
|
||||
pub avatar: String,
|
||||
pub csrf: String,
|
||||
@@ -50,9 +51,10 @@ impl Database {
|
||||
return Err(DatabaseError::InvalidCookiesError);
|
||||
}
|
||||
|
||||
// parse uid
|
||||
let uid = if platform == PlatformType::BiliBili {
|
||||
cookies
|
||||
// parse uid and id_str based on platform
|
||||
let (uid, id_str) = if platform == PlatformType::BiliBili {
|
||||
// For Bilibili, extract numeric uid from cookies
|
||||
let uid = cookies
|
||||
.split("DedeUserID=")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
@@ -63,15 +65,18 @@ impl Database {
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| DatabaseError::InvalidCookiesError)?
|
||||
.map_err(|_| DatabaseError::InvalidCookiesError)?;
|
||||
(uid, None)
|
||||
} else {
|
||||
// generate a random uid
|
||||
rand::thread_rng().gen_range(10000..=i32::MAX) as u64
|
||||
// For Douyin, use temporary uid and will set id_str later with real sec_uid
|
||||
let temp_uid = rand::thread_rng().gen_range(10000..=i32::MAX) as u64;
|
||||
(temp_uid, Some(format!("temp_{}", temp_uid)))
|
||||
};
|
||||
|
||||
let account = AccountRow {
|
||||
platform: platform.as_str().to_string(),
|
||||
uid,
|
||||
id_str,
|
||||
name: "".into(),
|
||||
avatar: "".into(),
|
||||
csrf: csrf.unwrap(),
|
||||
@@ -79,7 +84,7 @@ impl Database {
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
sqlx::query("INSERT INTO accounts (uid, platform, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)").bind(account.uid as i64).bind(&account.platform).bind(&account.name).bind(&account.avatar).bind(&account.csrf).bind(&account.cookies).bind(&account.created_at).execute(&lock).await?;
|
||||
sqlx::query("INSERT INTO accounts (uid, platform, id_str, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)").bind(account.uid as i64).bind(&account.platform).bind(&account.id_str).bind(&account.name).bind(&account.avatar).bind(&account.csrf).bind(&account.cookies).bind(&account.created_at).execute(&lock).await?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
@@ -120,6 +125,52 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_account_with_id_str(
|
||||
&self,
|
||||
old_account: &AccountRow,
|
||||
new_id_str: &str,
|
||||
name: &str,
|
||||
avatar: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
|
||||
// If the id_str changed, we need to delete the old record and create a new one
|
||||
if old_account.id_str.as_deref() != Some(new_id_str) {
|
||||
// Delete the old record (for Douyin accounts, we use uid to identify)
|
||||
sqlx::query("DELETE FROM accounts WHERE uid = $1 and platform = $2")
|
||||
.bind(old_account.uid as i64)
|
||||
.bind(&old_account.platform)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
|
||||
// Insert the new record with updated id_str
|
||||
sqlx::query("INSERT INTO accounts (uid, platform, id_str, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)")
|
||||
.bind(old_account.uid as i64)
|
||||
.bind(&old_account.platform)
|
||||
.bind(new_id_str)
|
||||
.bind(name)
|
||||
.bind(avatar)
|
||||
.bind(&old_account.csrf)
|
||||
.bind(&old_account.cookies)
|
||||
.bind(&old_account.created_at)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
} else {
|
||||
// id_str is the same, just update name and avatar
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3 and platform = $4",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(avatar)
|
||||
.bind(old_account.uid as i64)
|
||||
.bind(&old_account.platform)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_accounts(&self) -> Result<Vec<AccountRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts")
|
||||
|
||||
@@ -18,14 +18,21 @@ pub struct RecordRow {
|
||||
|
||||
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, title TEXT, length INTEGER, size INTEGER, created_at TEXT);
|
||||
impl Database {
|
||||
pub async fn get_records(&self, room_id: u64) -> Result<Vec<RecordRow>, DatabaseError> {
|
||||
pub async fn get_records(
|
||||
&self,
|
||||
room_id: u64,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
) -> Result<Vec<RecordRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(
|
||||
sqlx::query_as::<_, RecordRow>("SELECT * FROM records WHERE room_id = $1")
|
||||
.bind(room_id as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?,
|
||||
Ok(sqlx::query_as::<_, RecordRow>(
|
||||
"SELECT * FROM records WHERE room_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(room_id as i64)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_record(
|
||||
@@ -35,10 +42,10 @@ impl Database {
|
||||
) -> Result<RecordRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, RecordRow>(
|
||||
"SELECT * FROM records WHERE live_id = $1 and room_id = $2",
|
||||
"SELECT * FROM records WHERE room_id = $1 and live_id = $2",
|
||||
)
|
||||
.bind(live_id)
|
||||
.bind(room_id as i64)
|
||||
.bind(live_id)
|
||||
.fetch_one(&lock)
|
||||
.await?)
|
||||
}
|
||||
@@ -123,16 +130,36 @@ impl Database {
|
||||
|
||||
pub async fn get_recent_record(
|
||||
&self,
|
||||
room_id: u64,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
) -> Result<Vec<RecordRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, RecordRow>(
|
||||
"SELECT * FROM records ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
if room_id == 0 {
|
||||
Ok(sqlx::query_as::<_, RecordRow>(
|
||||
"SELECT * FROM records ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
} else {
|
||||
Ok(sqlx::query_as::<_, RecordRow>(
|
||||
"SELECT * FROM records WHERE room_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(room_id as i64)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_record_disk_usage(&self) -> Result<u64, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let result: (i64,) = sqlx::query_as("SELECT SUM(size) FROM records;")
|
||||
.fetch_one(&lock)
|
||||
.await?;
|
||||
Ok(result.0 as u64)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct RecorderRow {
|
||||
pub created_at: String,
|
||||
pub platform: String,
|
||||
pub auto_start: bool,
|
||||
pub extra: String,
|
||||
}
|
||||
|
||||
// recorders
|
||||
@@ -18,6 +19,7 @@ impl Database {
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
extra: &str,
|
||||
) -> Result<RecorderRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let recorder = RecorderRow {
|
||||
@@ -25,14 +27,16 @@ impl Database {
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
platform: platform.as_str().to_string(),
|
||||
auto_start: true,
|
||||
extra: extra.to_string(),
|
||||
};
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO recorders (room_id, created_at, platform, auto_start) VALUES ($1, $2, $3, $4)",
|
||||
"INSERT OR REPLACE INTO recorders (room_id, created_at, platform, auto_start, extra) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(room_id as i64)
|
||||
.bind(&recorder.created_at)
|
||||
.bind(platform.as_str())
|
||||
.bind(recorder.auto_start)
|
||||
.bind(extra)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(recorder)
|
||||
@@ -56,7 +60,7 @@ impl Database {
|
||||
pub async fn get_recorders(&self) -> Result<Vec<RecorderRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, RecorderRow>(
|
||||
"SELECT room_id, created_at, platform, auto_start FROM recorders",
|
||||
"SELECT room_id, created_at, platform, auto_start, extra FROM recorders",
|
||||
)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
use crate::progress_reporter::ProgressReporterTrait;
|
||||
use async_ffmpeg_sidecar::event::FfmpegEvent;
|
||||
use crate::progress_reporter::{ProgressReporter, ProgressReporterTrait};
|
||||
use crate::subtitle_generator::whisper_online;
|
||||
use crate::subtitle_generator::{
|
||||
whisper_cpp, GenerateResult, SubtitleGenerator, SubtitleGeneratorType,
|
||||
};
|
||||
use async_ffmpeg_sidecar::event::{FfmpegEvent, LogLevel};
|
||||
use async_ffmpeg_sidecar::log_parser::FfmpegLogParser;
|
||||
use tokio::io::BufReader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
// 视频元数据结构
|
||||
#[derive(Debug)]
|
||||
pub struct VideoMetadata {
|
||||
pub duration: f64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(unused_imports)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Range {
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
impl fmt::Display for Range {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "[{}, {}]", self.start, self.end)
|
||||
}
|
||||
}
|
||||
|
||||
impl Range {
|
||||
pub fn duration(&self) -> f64 {
|
||||
self.end - self.start
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clip_from_m3u8(
|
||||
reporter: Option<&impl ProgressReporterTrait>,
|
||||
m3u8_index: &Path,
|
||||
output_path: &Path,
|
||||
range: Option<&Range>,
|
||||
fix_encoding: bool,
|
||||
) -> Result<(), String> {
|
||||
// first check output folder exists
|
||||
let output_folder = output_path.parent().unwrap();
|
||||
@@ -21,9 +61,28 @@ pub async fn clip_from_m3u8(
|
||||
std::fs::create_dir_all(output_folder).unwrap();
|
||||
}
|
||||
|
||||
let child = tokio::process::Command::new(ffmpeg_path())
|
||||
.args(["-i", &format!("{}", m3u8_index.display())])
|
||||
.args(["-c", "copy"])
|
||||
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child_command = ffmpeg_process.args(["-i", &format!("{}", m3u8_index.display())]);
|
||||
|
||||
if let Some(range) = range {
|
||||
child_command
|
||||
.args(["-ss", &range.start.to_string()])
|
||||
.args(["-t", &range.duration().to_string()]);
|
||||
}
|
||||
|
||||
if fix_encoding {
|
||||
child_command
|
||||
.args(["-c:v", "libx264"])
|
||||
.args(["-c:a", "aac"])
|
||||
.args(["-preset", "fast"]);
|
||||
} else {
|
||||
child_command.args(["-c", "copy"]);
|
||||
}
|
||||
|
||||
let child = child_command
|
||||
.args(["-y", output_path.to_str().unwrap()])
|
||||
.args(["-progress", "pipe:2"])
|
||||
.stderr(Stdio::piped())
|
||||
@@ -45,13 +104,17 @@ pub async fn clip_from_m3u8(
|
||||
if reporter.is_none() {
|
||||
continue;
|
||||
}
|
||||
log::debug!("Clip progress: {}", p.time);
|
||||
reporter
|
||||
.unwrap()
|
||||
.update(format!("编码中:{}", p.time).as_str())
|
||||
}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(_level, content) => {
|
||||
log::debug!("{}", content);
|
||||
FfmpegEvent::Log(level, content) => {
|
||||
// log error if content contains error
|
||||
if content.contains("error") || level == LogLevel::Error {
|
||||
log::error!("Clip error: {}", content);
|
||||
}
|
||||
}
|
||||
FfmpegEvent::Error(e) => {
|
||||
log::error!("Clip error: {}", e);
|
||||
@@ -75,22 +138,92 @@ pub async fn clip_from_m3u8(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract_audio(file: &Path, format: &str) -> Result<(), String> {
|
||||
pub async fn extract_audio_chunks(file: &Path, format: &str) -> Result<PathBuf, String> {
|
||||
// ffmpeg -i fixed_\[30655190\]1742887114_0325084106_81.5.mp4 -ar 16000 test.wav
|
||||
log::info!("Extract audio task start: {}", file.display());
|
||||
let output_path = file.with_extension(format);
|
||||
let mut extract_error = None;
|
||||
|
||||
let sample_rate = if format == "mp3" { "32000" } else { "16000" };
|
||||
// 降低采样率以提高处理速度,同时保持足够的音质用于语音识别
|
||||
let sample_rate = if format == "mp3" { "22050" } else { "16000" };
|
||||
|
||||
let child = tokio::process::Command::new(ffmpeg_path())
|
||||
.args(["-i", file.to_str().unwrap()])
|
||||
.args(["-ar", sample_rate])
|
||||
.args([output_path.to_str().unwrap()])
|
||||
.args(["-y"])
|
||||
.args(["-progress", "pipe:2"])
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
// First, get the duration of the input file
|
||||
let duration = get_audio_duration(file).await?;
|
||||
log::info!("Audio duration: {} seconds", duration);
|
||||
|
||||
// Split into chunks of 30 seconds
|
||||
let chunk_duration = 30;
|
||||
let chunk_count = (duration as f64 / chunk_duration as f64).ceil() as usize;
|
||||
log::info!(
|
||||
"Splitting into {} chunks of {} seconds each",
|
||||
chunk_count,
|
||||
chunk_duration
|
||||
);
|
||||
|
||||
// Create output directory for chunks
|
||||
let output_dir = output_path.parent().unwrap();
|
||||
let base_name = output_path.file_stem().unwrap().to_str().unwrap();
|
||||
let chunk_dir = output_dir.join(format!("{}_chunks", base_name));
|
||||
|
||||
if !chunk_dir.exists() {
|
||||
std::fs::create_dir_all(&chunk_dir)
|
||||
.map_err(|e| format!("Failed to create chunk directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Use ffmpeg segment feature to split audio into chunks
|
||||
let segment_pattern = chunk_dir.join(format!("{}_%03d.{}", base_name, format));
|
||||
|
||||
// 构建优化的ffmpeg命令参数
|
||||
let file_str = file.to_str().unwrap();
|
||||
let chunk_duration_str = chunk_duration.to_string();
|
||||
let segment_pattern_str = segment_pattern.to_str().unwrap();
|
||||
|
||||
let mut args = vec![
|
||||
"-i",
|
||||
file_str,
|
||||
"-ar",
|
||||
sample_rate,
|
||||
"-vn",
|
||||
"-f",
|
||||
"segment",
|
||||
"-segment_time",
|
||||
&chunk_duration_str,
|
||||
"-reset_timestamps",
|
||||
"1",
|
||||
"-y",
|
||||
"-progress",
|
||||
"pipe:2",
|
||||
];
|
||||
|
||||
// 根据格式添加优化的编码参数
|
||||
if format == "mp3" {
|
||||
args.extend_from_slice(&[
|
||||
"-c:a",
|
||||
"mp3",
|
||||
"-b:a",
|
||||
"64k", // 降低比特率以提高速度
|
||||
"-compression_level",
|
||||
"0", // 最快压缩
|
||||
]);
|
||||
} else {
|
||||
args.extend_from_slice(&[
|
||||
"-c:a",
|
||||
"pcm_s16le", // 使用PCM编码,速度更快
|
||||
]);
|
||||
}
|
||||
|
||||
// 添加性能优化参数
|
||||
args.extend_from_slice(&[
|
||||
"-threads", "0", // 使用所有可用CPU核心
|
||||
]);
|
||||
|
||||
args.push(segment_pattern_str);
|
||||
|
||||
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child = ffmpeg_process.args(&args).stderr(Stdio::piped()).spawn();
|
||||
|
||||
if let Err(e) = child {
|
||||
return Err(e.to_string());
|
||||
@@ -108,9 +241,7 @@ pub async fn extract_audio(file: &Path, format: &str) -> Result<(), String> {
|
||||
extract_error = Some(e.to_string());
|
||||
}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(_level, content) => {
|
||||
log::debug!("{}", content);
|
||||
}
|
||||
FfmpegEvent::Log(_level, _content) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -124,11 +255,114 @@ pub async fn extract_audio(file: &Path, format: &str) -> Result<(), String> {
|
||||
log::error!("Extract audio error: {}", error);
|
||||
Err(error)
|
||||
} else {
|
||||
log::info!("Extract audio task end: {}", output_path.display());
|
||||
Ok(())
|
||||
log::info!(
|
||||
"Extract audio task end: {} chunks created in {}",
|
||||
chunk_count,
|
||||
chunk_dir.display()
|
||||
);
|
||||
Ok(chunk_dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the duration of an audio/video file in seconds
|
||||
async fn get_audio_duration(file: &Path) -> Result<u64, String> {
|
||||
// Use ffprobe with format option to get duration
|
||||
let mut ffprobe_process = tokio::process::Command::new(ffprobe_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffprobe_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child = ffprobe_process
|
||||
.args(["-v", "quiet"])
|
||||
.args(["-show_entries", "format=duration"])
|
||||
.args(["-of", "csv=p=0"])
|
||||
.args(["-i", file.to_str().unwrap()])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
if let Err(e) = child {
|
||||
return Err(format!("Failed to spawn ffprobe process: {}", e));
|
||||
}
|
||||
|
||||
let mut child = child.unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut parser = FfmpegLogParser::new(reader);
|
||||
|
||||
let mut duration = None;
|
||||
while let Ok(event) = parser.parse_next_event().await {
|
||||
match event {
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(_level, content) => {
|
||||
// The new command outputs duration directly as a float
|
||||
if let Ok(seconds_f64) = content.trim().parse::<f64>() {
|
||||
duration = Some(seconds_f64.ceil() as u64);
|
||||
log::debug!("Parsed duration: {} seconds", seconds_f64);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = child.wait().await {
|
||||
log::error!("Failed to get duration: {}", e);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
duration.ok_or_else(|| "Failed to parse duration".to_string())
|
||||
}
|
||||
|
||||
/// Get the precise duration of a video segment (TS/MP4) in seconds
|
||||
pub async fn get_segment_duration(file: &Path) -> Result<f64, String> {
|
||||
// Use ffprobe to get the exact duration of the segment
|
||||
let mut ffprobe_process = tokio::process::Command::new(ffprobe_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffprobe_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child = ffprobe_process
|
||||
.args(["-v", "quiet"])
|
||||
.args(["-show_entries", "format=duration"])
|
||||
.args(["-of", "csv=p=0"])
|
||||
.args(["-i", file.to_str().unwrap()])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
if let Err(e) = child {
|
||||
return Err(format!(
|
||||
"Failed to spawn ffprobe process for segment: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
|
||||
let mut child = child.unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut parser = FfmpegLogParser::new(reader);
|
||||
|
||||
let mut duration = None;
|
||||
while let Ok(event) = parser.parse_next_event().await {
|
||||
match event {
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(_level, content) => {
|
||||
// Parse the exact duration as f64 for precise timing
|
||||
if let Ok(seconds_f64) = content.trim().parse::<f64>() {
|
||||
duration = Some(seconds_f64);
|
||||
log::debug!("Parsed segment duration: {} seconds", seconds_f64);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = child.wait().await {
|
||||
log::error!("Failed to get segment duration: {}", e);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
duration.ok_or_else(|| "Failed to parse segment duration".to_string())
|
||||
}
|
||||
|
||||
pub async fn encode_video_subtitle(
|
||||
reporter: &impl ProgressReporterTrait,
|
||||
file: &Path,
|
||||
@@ -142,10 +376,9 @@ pub async fn encode_video_subtitle(
|
||||
let output_filename = format!("[subtitle]{}", file.file_name().unwrap().to_str().unwrap());
|
||||
let output_path = file.with_file_name(&output_filename);
|
||||
|
||||
// check output path exists
|
||||
// check output path exists - log but allow overwrite
|
||||
if output_path.exists() {
|
||||
log::info!("Output path already exists: {}", output_path.display());
|
||||
return Err("Output path already exists".to_string());
|
||||
log::info!("Output path already exists, will overwrite: {}", output_path.display());
|
||||
}
|
||||
|
||||
let mut command_error = None;
|
||||
@@ -165,7 +398,11 @@ pub async fn encode_video_subtitle(
|
||||
let vf = format!("subtitles={}:force_style='{}'", subtitle, srt_style);
|
||||
log::info!("vf: {}", vf);
|
||||
|
||||
let child = tokio::process::Command::new(ffmpeg_path())
|
||||
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child = ffmpeg_process
|
||||
.args(["-i", file.to_str().unwrap()])
|
||||
.args(["-vf", vf.as_str()])
|
||||
.args(["-c:v", "libx264"])
|
||||
@@ -196,9 +433,7 @@ pub async fn encode_video_subtitle(
|
||||
reporter.update(format!("压制中:{}", p.time).as_str());
|
||||
}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(_level, content) => {
|
||||
log::debug!("{}", content);
|
||||
}
|
||||
FfmpegEvent::Log(_level, _content) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -227,10 +462,9 @@ pub async fn encode_video_danmu(
|
||||
let danmu_filename = format!("[danmu]{}", file.file_name().unwrap().to_str().unwrap());
|
||||
let output_path = file.with_file_name(danmu_filename);
|
||||
|
||||
// check output path exists
|
||||
// check output path exists - log but allow overwrite
|
||||
if output_path.exists() {
|
||||
log::info!("Output path already exists: {}", output_path.display());
|
||||
return Err("Output path already exists".to_string());
|
||||
log::info!("Output path already exists, will overwrite: {}", output_path.display());
|
||||
}
|
||||
|
||||
let mut command_error = None;
|
||||
@@ -248,7 +482,11 @@ pub async fn encode_video_danmu(
|
||||
format!("'{}'", subtitle.display())
|
||||
};
|
||||
|
||||
let child = tokio::process::Command::new(ffmpeg_path())
|
||||
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child = ffmpeg_process
|
||||
.args(["-i", file.to_str().unwrap()])
|
||||
.args(["-vf", &format!("ass={}", subtitle)])
|
||||
.args(["-c:v", "libx264"])
|
||||
@@ -275,7 +513,7 @@ pub async fn encode_video_danmu(
|
||||
command_error = Some(e.to_string());
|
||||
}
|
||||
FfmpegEvent::Progress(p) => {
|
||||
log::info!("Encode video danmu progress: {}", p.time);
|
||||
log::debug!("Encode video danmu progress: {}", p.time);
|
||||
if reporter.is_none() {
|
||||
continue;
|
||||
}
|
||||
@@ -283,9 +521,7 @@ pub async fn encode_video_danmu(
|
||||
.unwrap()
|
||||
.update(format!("压制中:{}", p.time).as_str());
|
||||
}
|
||||
FfmpegEvent::Log(_level, content) => {
|
||||
log::debug!("{}", content);
|
||||
}
|
||||
FfmpegEvent::Log(_level, _content) => {}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
_ => {}
|
||||
}
|
||||
@@ -305,6 +541,166 @@ pub async fn encode_video_danmu(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generic_ffmpeg_command(args: &[&str]) -> Result<String, String> {
|
||||
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child = ffmpeg_process.args(args).stderr(Stdio::piped()).spawn();
|
||||
if let Err(e) = child {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
let mut child = child.unwrap();
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut parser = FfmpegLogParser::new(reader);
|
||||
|
||||
let mut logs = Vec::new();
|
||||
|
||||
while let Ok(event) = parser.parse_next_event().await {
|
||||
match event {
|
||||
FfmpegEvent::Log(_level, content) => {
|
||||
logs.push(content);
|
||||
}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = child.wait().await {
|
||||
log::error!("Generic ffmpeg command error: {}", e);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
Ok(logs.join("\n"))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn generate_video_subtitle(
|
||||
reporter: Option<&ProgressReporter>,
|
||||
file: &Path,
|
||||
generator_type: &str,
|
||||
whisper_model: &str,
|
||||
whisper_prompt: &str,
|
||||
openai_api_key: &str,
|
||||
openai_api_endpoint: &str,
|
||||
language_hint: &str,
|
||||
) -> Result<GenerateResult, String> {
|
||||
match generator_type {
|
||||
"whisper" => {
|
||||
if whisper_model.is_empty() {
|
||||
return Err("Whisper model not configured".to_string());
|
||||
}
|
||||
if let Ok(generator) = whisper_cpp::new(Path::new(&whisper_model), whisper_prompt).await
|
||||
{
|
||||
let chunk_dir = extract_audio_chunks(file, "wav").await?;
|
||||
|
||||
let mut full_result = GenerateResult {
|
||||
subtitle_id: "".to_string(),
|
||||
subtitle_content: vec![],
|
||||
generator_type: SubtitleGeneratorType::Whisper,
|
||||
};
|
||||
|
||||
let mut chunk_paths = vec![];
|
||||
for entry in std::fs::read_dir(&chunk_dir)
|
||||
.map_err(|e| format!("Failed to read chunk directory: {}", e))?
|
||||
{
|
||||
let entry =
|
||||
entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
chunk_paths.push(path);
|
||||
}
|
||||
|
||||
// sort chunk paths by name
|
||||
chunk_paths
|
||||
.sort_by_key(|path| path.file_name().unwrap().to_str().unwrap().to_string());
|
||||
|
||||
let mut results = Vec::new();
|
||||
for path in chunk_paths {
|
||||
let result = generator
|
||||
.generate_subtitle(reporter, &path, language_hint)
|
||||
.await;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
if let Ok(result) = result {
|
||||
full_result.subtitle_id = result.subtitle_id.clone();
|
||||
full_result.concat(result, 30 * i as u64);
|
||||
}
|
||||
}
|
||||
|
||||
// delete chunk directory
|
||||
let _ = tokio::fs::remove_dir_all(chunk_dir).await;
|
||||
|
||||
Ok(full_result)
|
||||
} else {
|
||||
Err("Failed to initialize Whisper model".to_string())
|
||||
}
|
||||
}
|
||||
"whisper_online" => {
|
||||
if openai_api_key.is_empty() {
|
||||
return Err("API key not configured".to_string());
|
||||
}
|
||||
if let Ok(generator) = whisper_online::new(
|
||||
Some(openai_api_endpoint),
|
||||
Some(openai_api_key),
|
||||
Some(whisper_prompt),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let chunk_dir = extract_audio_chunks(file, "mp3").await?;
|
||||
|
||||
let mut full_result = GenerateResult {
|
||||
subtitle_id: "".to_string(),
|
||||
subtitle_content: vec![],
|
||||
generator_type: SubtitleGeneratorType::WhisperOnline,
|
||||
};
|
||||
|
||||
let mut chunk_paths = vec![];
|
||||
for entry in std::fs::read_dir(&chunk_dir)
|
||||
.map_err(|e| format!("Failed to read chunk directory: {}", e))?
|
||||
{
|
||||
let entry =
|
||||
entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
chunk_paths.push(path);
|
||||
}
|
||||
// sort chunk paths by name
|
||||
chunk_paths
|
||||
.sort_by_key(|path| path.file_name().unwrap().to_str().unwrap().to_string());
|
||||
|
||||
let mut results = Vec::new();
|
||||
for path in chunk_paths {
|
||||
let result = generator
|
||||
.generate_subtitle(reporter, &path, language_hint)
|
||||
.await;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
if let Ok(result) = result {
|
||||
full_result.subtitle_id = result.subtitle_id.clone();
|
||||
full_result.concat(result, 30 * i as u64);
|
||||
}
|
||||
}
|
||||
|
||||
// delete chunk directory
|
||||
let _ = tokio::fs::remove_dir_all(chunk_dir).await;
|
||||
|
||||
Ok(full_result)
|
||||
} else {
|
||||
Err("Failed to initialize Whisper Online".to_string())
|
||||
}
|
||||
}
|
||||
_ => Err(format!(
|
||||
"Unknown subtitle generator type: {}",
|
||||
generator_type
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trying to run ffmpeg for version
|
||||
pub async fn check_ffmpeg() -> Result<String, String> {
|
||||
let child = tokio::process::Command::new(ffmpeg_path())
|
||||
@@ -344,6 +740,52 @@ pub async fn check_ffmpeg() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_video_resolution(file: &str) -> Result<String, String> {
|
||||
// ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 input.mp4
|
||||
let mut ffprobe_process = tokio::process::Command::new(ffprobe_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffprobe_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child = ffprobe_process
|
||||
.arg("-i")
|
||||
.arg(file)
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
.arg("-select_streams")
|
||||
.arg("v:0")
|
||||
.arg("-show_entries")
|
||||
.arg("stream=width,height")
|
||||
.arg("-of")
|
||||
.arg("csv=s=x:p=0")
|
||||
.stdout(Stdio::piped())
|
||||
.spawn();
|
||||
if let Err(e) = child {
|
||||
log::error!("Faild to spwan ffprobe process: {e}");
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
let mut child = child.unwrap();
|
||||
let stdout = child.stdout.take();
|
||||
if stdout.is_none() {
|
||||
log::error!("Failed to take ffprobe output");
|
||||
return Err("Failed to take ffprobe output".into());
|
||||
}
|
||||
|
||||
let stdout = stdout.unwrap();
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
let line = lines.next_line().await.unwrap();
|
||||
if line.is_none() {
|
||||
return Err("Failed to parse resolution from output".into());
|
||||
}
|
||||
let line = line.unwrap();
|
||||
let resolution = line.split("x").collect::<Vec<&str>>();
|
||||
if resolution.len() != 2 {
|
||||
return Err("Failed to parse resolution from output".into());
|
||||
}
|
||||
Ok(format!("{}x{}", resolution[0], resolution[1]))
|
||||
}
|
||||
|
||||
fn ffmpeg_path() -> PathBuf {
|
||||
let mut path = Path::new("ffmpeg").to_path_buf();
|
||||
if cfg!(windows) {
|
||||
@@ -352,3 +794,365 @@ fn ffmpeg_path() -> PathBuf {
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
fn ffprobe_path() -> PathBuf {
|
||||
let mut path = Path::new("ffprobe").to_path_buf();
|
||||
if cfg!(windows) {
|
||||
path.set_extension("exe");
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
// 解析 FFmpeg 时间字符串 (格式如 "00:01:23.45")
|
||||
fn parse_time_string(time_str: &str) -> Result<f64, String> {
|
||||
let parts: Vec<&str> = time_str.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("Invalid time format".to_string());
|
||||
}
|
||||
|
||||
let hours: f64 = parts[0].parse().map_err(|_| "Invalid hours")?;
|
||||
let minutes: f64 = parts[1].parse().map_err(|_| "Invalid minutes")?;
|
||||
let seconds: f64 = parts[2].parse().map_err(|_| "Invalid seconds")?;
|
||||
|
||||
Ok(hours * 3600.0 + minutes * 60.0 + seconds)
|
||||
}
|
||||
|
||||
// 从视频文件切片
|
||||
pub async fn clip_from_video_file(
|
||||
reporter: Option<&impl ProgressReporterTrait>,
|
||||
input_path: &Path,
|
||||
output_path: &Path,
|
||||
start_time: f64,
|
||||
duration: f64,
|
||||
) -> Result<(), String> {
|
||||
let output_folder = output_path.parent().unwrap();
|
||||
if !output_folder.exists() {
|
||||
std::fs::create_dir_all(output_folder).unwrap();
|
||||
}
|
||||
|
||||
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let child = ffmpeg_process
|
||||
.args(["-i", &format!("{}", input_path.display())])
|
||||
.args(["-ss", &start_time.to_string()])
|
||||
.args(["-t", &duration.to_string()])
|
||||
.args(["-c:v", "libx264"])
|
||||
.args(["-c:a", "aac"])
|
||||
.args(["-preset", "fast"])
|
||||
.args(["-crf", "23"])
|
||||
.args(["-avoid_negative_ts", "make_zero"])
|
||||
.args(["-y", output_path.to_str().unwrap()])
|
||||
.args(["-progress", "pipe:2"])
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
if let Err(e) = child {
|
||||
return Err(format!("启动ffmpeg进程失败: {}", e));
|
||||
}
|
||||
|
||||
let mut child = child.unwrap();
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut parser = FfmpegLogParser::new(reader);
|
||||
|
||||
let mut clip_error = None;
|
||||
while let Ok(event) = parser.parse_next_event().await {
|
||||
match event {
|
||||
FfmpegEvent::Progress(p) => {
|
||||
if let Some(reporter) = reporter {
|
||||
// 解析时间字符串 (格式如 "00:01:23.45")
|
||||
if let Ok(current_time) = parse_time_string(&p.time) {
|
||||
let progress = (current_time / duration * 100.0).min(100.0);
|
||||
reporter.update(&format!("切片进度: {:.1}%", progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(level, content) => {
|
||||
if content.contains("error") || level == LogLevel::Error {
|
||||
log::error!("切片错误: {}", content);
|
||||
}
|
||||
}
|
||||
FfmpegEvent::Error(e) => {
|
||||
log::error!("切片错误: {}", e);
|
||||
clip_error = Some(e.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = child.wait().await {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
if let Some(error) = clip_error {
|
||||
Err(error)
|
||||
} else {
|
||||
log::info!("切片任务完成: {}", output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// 获取视频元数据
|
||||
pub async fn extract_video_metadata(file_path: &Path) -> Result<VideoMetadata, String> {
|
||||
let mut ffprobe_process = tokio::process::Command::new("ffprobe");
|
||||
#[cfg(target_os = "windows")]
|
||||
ffprobe_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let output = ffprobe_process
|
||||
.args([
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
"-select_streams", "v:0",
|
||||
&format!("{}", file_path.display())
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行ffprobe失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("ffprobe执行失败: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
|
||||
let json_str = String::from_utf8_lossy(&output.stdout);
|
||||
let json: serde_json::Value = serde_json::from_str(&json_str)
|
||||
.map_err(|e| format!("解析ffprobe输出失败: {}", e))?;
|
||||
|
||||
// 解析视频流信息
|
||||
let streams = json["streams"].as_array()
|
||||
.ok_or("未找到视频流信息")?;
|
||||
|
||||
if streams.is_empty() {
|
||||
return Err("未找到视频流".to_string());
|
||||
}
|
||||
|
||||
let video_stream = &streams[0];
|
||||
let format = &json["format"];
|
||||
|
||||
let duration = format["duration"].as_str()
|
||||
.and_then(|d| d.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let width = video_stream["width"].as_u64().unwrap_or(0) as u32;
|
||||
let height = video_stream["height"].as_u64().unwrap_or(0) as u32;
|
||||
|
||||
Ok(VideoMetadata {
|
||||
duration,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
// 生成视频缩略图
|
||||
pub async fn generate_thumbnail(
|
||||
video_path: &Path,
|
||||
output_path: &Path,
|
||||
timestamp: f64,
|
||||
) -> Result<(), String> {
|
||||
let output_folder = output_path.parent().unwrap();
|
||||
if !output_folder.exists() {
|
||||
std::fs::create_dir_all(output_folder).unwrap();
|
||||
}
|
||||
|
||||
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let output = ffmpeg_process
|
||||
.args(["-i", &format!("{}", video_path.display())])
|
||||
.args(["-ss", ×tamp.to_string()])
|
||||
.args(["-vframes", "1"])
|
||||
.args(["-y", output_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("生成缩略图失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("ffmpeg生成缩略图失败: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
|
||||
// 记录生成的缩略图信息
|
||||
if let Ok(metadata) = std::fs::metadata(output_path) {
|
||||
log::info!("生成缩略图完成: {} (文件大小: {} bytes)", output_path.display(), metadata.len());
|
||||
} else {
|
||||
log::info!("生成缩略图完成: {}", output_path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 解析FFmpeg时间字符串为秒数 (格式: "HH:MM:SS.mmm")
|
||||
pub fn parse_ffmpeg_time(time_str: &str) -> Result<f64, String> {
|
||||
let parts: Vec<&str> = time_str.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!("Invalid time format: {}", time_str));
|
||||
}
|
||||
|
||||
let hours: f64 = parts[0].parse().map_err(|_| format!("Invalid hours: {}", parts[0]))?;
|
||||
let minutes: f64 = parts[1].parse().map_err(|_| format!("Invalid minutes: {}", parts[1]))?;
|
||||
let seconds: f64 = parts[2].parse().map_err(|_| format!("Invalid seconds: {}", parts[2]))?;
|
||||
|
||||
Ok(hours * 3600.0 + minutes * 60.0 + seconds)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 执行FFmpeg转换的通用函数
|
||||
pub async fn execute_ffmpeg_conversion(
|
||||
mut cmd: tokio::process::Command,
|
||||
total_duration: f64,
|
||||
reporter: &ProgressReporter,
|
||||
mode_name: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Stdio;
|
||||
use tokio::io::BufReader;
|
||||
use async_ffmpeg_sidecar::event::FfmpegEvent;
|
||||
use async_ffmpeg_sidecar::log_parser::FfmpegLogParser;
|
||||
|
||||
let mut child = cmd
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动FFmpeg进程失败: {}", e))?;
|
||||
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut parser = FfmpegLogParser::new(reader);
|
||||
|
||||
let mut conversion_error = None;
|
||||
while let Ok(event) = parser.parse_next_event().await {
|
||||
match event {
|
||||
FfmpegEvent::Progress(p) => {
|
||||
if total_duration > 0.0 {
|
||||
// 解析时间字符串为浮点数 (格式: "HH:MM:SS.mmm")
|
||||
if let Ok(current_time) = parse_ffmpeg_time(&p.time) {
|
||||
let progress = (current_time / total_duration * 100.0).min(100.0);
|
||||
reporter.update(&format!("正在转换视频格式... {:.1}% ({})", progress, mode_name));
|
||||
} else {
|
||||
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
|
||||
}
|
||||
} else {
|
||||
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
|
||||
}
|
||||
}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(level, content) => {
|
||||
if matches!(level, async_ffmpeg_sidecar::event::LogLevel::Error) && content.contains("Error") {
|
||||
conversion_error = Some(content);
|
||||
}
|
||||
}
|
||||
FfmpegEvent::Error(e) => {
|
||||
conversion_error = Some(e);
|
||||
}
|
||||
_ => {} // 忽略其他事件类型
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| format!("等待FFmpeg进程失败: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
let error_msg = conversion_error.unwrap_or_else(|| format!("FFmpeg退出码: {}", status.code().unwrap_or(-1)));
|
||||
return Err(format!("视频格式转换失败 ({}): {}", mode_name, error_msg));
|
||||
}
|
||||
|
||||
reporter.update(&format!("视频格式转换完成 100% ({})", mode_name));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 尝试流复制转换(无损,速度快)
|
||||
pub async fn try_stream_copy_conversion(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 获取视频时长以计算进度
|
||||
let metadata = extract_video_metadata(source).await?;
|
||||
let total_duration = metadata.duration;
|
||||
|
||||
reporter.update("正在转换视频格式... 0% (无损模式)");
|
||||
|
||||
// 构建ffmpeg命令 - 流复制模式
|
||||
let mut cmd = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
|
||||
cmd.args([
|
||||
"-i", &source.to_string_lossy(),
|
||||
"-c:v", "copy", // 直接复制视频流,零损失
|
||||
"-c:a", "copy", // 直接复制音频流,零损失
|
||||
"-avoid_negative_ts", "make_zero", // 修复时间戳问题
|
||||
"-movflags", "+faststart", // 优化web播放
|
||||
"-progress", "pipe:2", // 输出进度到stderr
|
||||
"-y", // 覆盖输出文件
|
||||
&dest.to_string_lossy(),
|
||||
]);
|
||||
|
||||
execute_ffmpeg_conversion(cmd, total_duration, reporter, "无损转换").await
|
||||
}
|
||||
|
||||
// 高质量重编码转换(兼容性好,质量高)
|
||||
pub async fn try_high_quality_conversion(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 获取视频时长以计算进度
|
||||
let metadata = extract_video_metadata(source).await?;
|
||||
let total_duration = metadata.duration;
|
||||
|
||||
reporter.update("正在转换视频格式... 0% (高质量模式)");
|
||||
|
||||
// 构建ffmpeg命令 - 高质量重编码
|
||||
let mut cmd = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
|
||||
cmd.args([
|
||||
"-i", &source.to_string_lossy(),
|
||||
"-c:v", "libx264", // H.264编码器
|
||||
"-preset", "slow", // 慢速预设,更好的压缩效率
|
||||
"-crf", "18", // 高质量设置 (18-23范围,越小质量越高)
|
||||
"-c:a", "aac", // AAC音频编码器
|
||||
"-b:a", "192k", // 高音频码率
|
||||
"-avoid_negative_ts", "make_zero", // 修复时间戳问题
|
||||
"-movflags", "+faststart", // 优化web播放
|
||||
"-progress", "pipe:2", // 输出进度到stderr
|
||||
"-y", // 覆盖输出文件
|
||||
&dest.to_string_lossy(),
|
||||
]);
|
||||
|
||||
execute_ffmpeg_conversion(cmd, total_duration, reporter, "高质量转换").await
|
||||
}
|
||||
|
||||
// 带进度的视频格式转换函数(智能质量保持策略)
|
||||
pub async fn convert_video_format(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 先尝试stream copy(无损转换),如果失败则使用高质量重编码
|
||||
match try_stream_copy_conversion(source, dest, reporter).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(stream_copy_error) => {
|
||||
reporter.update("流复制失败,使用高质量重编码模式...");
|
||||
log::warn!("Stream copy failed: {}, falling back to re-encoding", stream_copy_error);
|
||||
try_high_quality_conversion(source, dest, reporter).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tests
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_video_size() {
|
||||
let file = Path::new("/Users/xinreasuper/Desktop/shadowreplay-test/output2/[1789714684][1753965688317][摄像头被前夫抛妻弃子直播挣点奶粉][2025-07-31_12-58-14].mp4");
|
||||
let resolution = get_video_resolution(file.to_str().unwrap()).await.unwrap();
|
||||
println!("Resolution: {}", resolution);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::recorder::bilibili::client::{QrInfo, QrStatus};
|
||||
use crate::state::State;
|
||||
use crate::state_type;
|
||||
|
||||
use hyper::header::HeaderValue;
|
||||
#[cfg(feature = "gui")]
|
||||
use tauri::State as TauriState;
|
||||
|
||||
@@ -20,6 +21,10 @@ pub async fn add_account(
|
||||
platform: String,
|
||||
cookies: &str,
|
||||
) -> Result<AccountRow, String> {
|
||||
// check if cookies is valid
|
||||
if let Err(e) = cookies.parse::<HeaderValue>() {
|
||||
return Err(format!("Invalid cookies: {}", e));
|
||||
}
|
||||
let account = state.db.add_account(&platform, cookies).await?;
|
||||
if platform == "bilibili" {
|
||||
let account_info = state.client.get_user_info(&account, account.uid).await?;
|
||||
@@ -32,6 +37,37 @@ pub async fn add_account(
|
||||
&account_info.user_avatar_url,
|
||||
)
|
||||
.await?;
|
||||
} else if platform == "douyin" {
|
||||
// Get user info from Douyin API
|
||||
let douyin_client = crate::recorder::douyin::client::DouyinClient::new(
|
||||
&state.config.read().await.user_agent,
|
||||
&account,
|
||||
);
|
||||
match douyin_client.get_user_info().await {
|
||||
Ok(user_info) => {
|
||||
// For Douyin, use sec_uid as the primary identifier in id_str field
|
||||
let avatar_url = user_info
|
||||
.avatar_thumb
|
||||
.url_list
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
state
|
||||
.db
|
||||
.update_account_with_id_str(
|
||||
&account,
|
||||
&user_info.sec_uid,
|
||||
&user_info.nickname,
|
||||
&avatar_url,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get Douyin user info: {}", e);
|
||||
// Keep the account but with default values
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
@@ -234,3 +234,21 @@ pub async fn update_status_check_interval(
|
||||
state.config.write().await.save();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn update_whisper_language(
|
||||
state: state_type!(),
|
||||
whisper_language: String,
|
||||
) -> Result<(), ()> {
|
||||
log::info!("Updating whisper language to {}", whisper_language);
|
||||
state.config.write().await.whisper_language = whisper_language;
|
||||
state.config.write().await.save();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn update_user_agent(state: state_type!(), user_agent: String) -> Result<(), ()> {
|
||||
log::info!("Updating user agent to {}", user_agent);
|
||||
state.config.write().await.set_user_agent(&user_agent);
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub async fn add_recorder(
|
||||
state: state_type!(),
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
extra: String,
|
||||
) -> Result<RecorderRow, String> {
|
||||
log::info!("Add recorder: {} {}", platform, room_id);
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
@@ -50,11 +51,11 @@ pub async fn add_recorder(
|
||||
match account {
|
||||
Ok(account) => match state
|
||||
.recorder_manager
|
||||
.add_recorder(&account, platform, room_id, true)
|
||||
.add_recorder(&account, platform, room_id, &extra, true)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let room = state.db.add_recorder(platform, room_id).await?;
|
||||
let room = state.db.add_recorder(platform, room_id, &extra).await?;
|
||||
state
|
||||
.db
|
||||
.new_message("添加直播间", &format!("添加了新直播间 {}", room_id))
|
||||
@@ -120,8 +121,21 @@ pub async fn get_room_info(
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn get_archives(state: state_type!(), room_id: u64) -> Result<Vec<RecordRow>, String> {
|
||||
Ok(state.recorder_manager.get_archives(room_id).await?)
|
||||
pub async fn get_archive_disk_usage(state: state_type!()) -> Result<u64, String> {
|
||||
Ok(state.recorder_manager.get_archive_disk_usage().await?)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn get_archives(
|
||||
state: state_type!(),
|
||||
room_id: u64,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
) -> Result<Vec<RecordRow>, String> {
|
||||
Ok(state
|
||||
.recorder_manager
|
||||
.get_archives(room_id, offset, limit)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
@@ -136,6 +150,40 @@ pub async fn get_archive(
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn get_archive_subtitle(
|
||||
state: state_type!(),
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
) -> Result<String, String> {
|
||||
let platform = PlatformType::from_str(&platform);
|
||||
if platform.is_none() {
|
||||
return Err("Unsupported platform".to_string());
|
||||
}
|
||||
Ok(state
|
||||
.recorder_manager
|
||||
.get_archive_subtitle(platform.unwrap(), room_id, &live_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn generate_archive_subtitle(
|
||||
state: state_type!(),
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
) -> Result<String, String> {
|
||||
let platform = PlatformType::from_str(&platform);
|
||||
if platform.is_none() {
|
||||
return Err("Unsupported platform".to_string());
|
||||
}
|
||||
Ok(state
|
||||
.recorder_manager
|
||||
.generate_archive_subtitle(platform.unwrap(), room_id, &live_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn delete_archive(
|
||||
state: state_type!(),
|
||||
@@ -143,10 +191,13 @@ pub async fn delete_archive(
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
) -> Result<(), String> {
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
let platform = PlatformType::from_str(&platform);
|
||||
if platform.is_none() {
|
||||
return Err("Unsupported platform".to_string());
|
||||
}
|
||||
state
|
||||
.recorder_manager
|
||||
.delete_archive(platform, room_id, &live_id)
|
||||
.delete_archive(platform.unwrap(), room_id, &live_id)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
@@ -165,10 +216,13 @@ pub async fn get_danmu_record(
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
) -> Result<Vec<DanmuEntry>, String> {
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
let platform = PlatformType::from_str(&platform);
|
||||
if platform.is_none() {
|
||||
return Err("Unsupported platform".to_string());
|
||||
}
|
||||
Ok(state
|
||||
.recorder_manager
|
||||
.get_danmu(platform, room_id, &live_id)
|
||||
.get_danmu(platform.unwrap(), room_id, &live_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
@@ -188,10 +242,13 @@ pub async fn export_danmu(
|
||||
state: state_type!(),
|
||||
options: ExportDanmuOptions,
|
||||
) -> Result<String, String> {
|
||||
let platform = PlatformType::from_str(&options.platform).unwrap();
|
||||
let platform = PlatformType::from_str(&options.platform);
|
||||
if platform.is_none() {
|
||||
return Err("Unsupported platform".to_string());
|
||||
}
|
||||
let mut danmus = state
|
||||
.recorder_manager
|
||||
.get_danmu(platform, options.room_id, &options.live_id)
|
||||
.get_danmu(platform.unwrap(), options.room_id, &options.live_id)
|
||||
.await?;
|
||||
|
||||
log::debug!("First danmu entry: {:?}", danmus.first());
|
||||
@@ -249,10 +306,11 @@ pub async fn get_today_record_count(state: state_type!()) -> Result<i64, String>
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn get_recent_record(
|
||||
state: state_type!(),
|
||||
room_id: u64,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
) -> Result<Vec<RecordRow>, String> {
|
||||
match state.db.get_recent_record(offset, limit).await {
|
||||
match state.db.get_recent_record(room_id, offset, limit).await {
|
||||
Ok(records) => Ok(records),
|
||||
Err(e) => Err(format!("Failed to get recent record: {}", e)),
|
||||
}
|
||||
@@ -266,10 +324,13 @@ pub async fn set_enable(
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Set enable for recorder {platform} {room_id} {enabled}");
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
let platform = PlatformType::from_str(&platform);
|
||||
if platform.is_none() {
|
||||
return Err("Unsupported platform".to_string());
|
||||
}
|
||||
state
|
||||
.recorder_manager
|
||||
.set_enable(platform, room_id, enabled)
|
||||
.set_enable(platform.unwrap(), room_id, enabled)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -288,3 +288,17 @@ pub async fn open_clip(state: state_type!(), video_id: i64) -> Result<(), String
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn list_folder(_state: state_type!(), path: String) -> Result<Vec<String>, String> {
|
||||
let path = PathBuf::from(path);
|
||||
let entries = std::fs::read_dir(path);
|
||||
if entries.is_err() {
|
||||
return Err(format!("Read directory failed: {}", entries.err().unwrap()));
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
for entry in entries.unwrap().flatten() {
|
||||
files.push(entry.path().to_str().unwrap().to_string());
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
@@ -17,22 +17,24 @@ use crate::{
|
||||
config::{
|
||||
get_config, update_auto_generate, update_clip_name_format, update_notify,
|
||||
update_openai_api_endpoint, update_openai_api_key, update_status_check_interval,
|
||||
update_subtitle_generator_type, update_subtitle_setting, update_whisper_model,
|
||||
update_whisper_prompt,
|
||||
update_subtitle_generator_type, update_subtitle_setting, update_user_agent,
|
||||
update_whisper_language, update_whisper_model, update_whisper_prompt,
|
||||
},
|
||||
message::{delete_message, get_messages, read_message},
|
||||
recorder::{
|
||||
add_recorder, delete_archive, export_danmu, fetch_hls, get_archive, get_archives,
|
||||
add_recorder, delete_archive, export_danmu, fetch_hls, generate_archive_subtitle,
|
||||
get_archive, get_archive_disk_usage, get_archive_subtitle, get_archives,
|
||||
get_danmu_record, get_recent_record, get_recorder_list, get_room_info,
|
||||
get_today_record_count, get_total_length, remove_recorder, send_danmaku, set_enable,
|
||||
ExportDanmuOptions,
|
||||
},
|
||||
task::{delete_task, get_tasks},
|
||||
utils::{console_log, get_disk_info, DiskInfo},
|
||||
utils::{console_log, get_disk_info, list_folder, DiskInfo},
|
||||
video::{
|
||||
cancel, clip_range, delete_video, encode_video_subtitle, generate_video_subtitle,
|
||||
get_all_videos, get_video, get_video_cover, get_video_subtitle, get_video_typelist,
|
||||
get_videos, update_video_cover, update_video_subtitle, upload_procedure,
|
||||
cancel, clip_range, clip_video, delete_video, encode_video_subtitle,
|
||||
generate_video_subtitle, generic_ffmpeg_command, get_all_videos, get_file_size,
|
||||
get_video, get_video_cover, get_video_subtitle, get_video_typelist, get_videos,
|
||||
import_external_video, update_video_cover, update_video_subtitle, upload_procedure,
|
||||
},
|
||||
AccountInfo,
|
||||
},
|
||||
@@ -51,7 +53,7 @@ use crate::{
|
||||
};
|
||||
use axum::{extract::Query, response::sse};
|
||||
use axum::{
|
||||
extract::{DefaultBodyLimit, Json, Path},
|
||||
extract::{DefaultBodyLimit, Json, Multipart, Path},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Sse},
|
||||
routing::{get, post},
|
||||
@@ -251,12 +253,44 @@ async fn handler_update_whisper_model(
|
||||
Ok(Json(ApiResponse::success(())))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateWhisperLanguageRequest {
|
||||
whisper_language: String,
|
||||
}
|
||||
|
||||
async fn handler_update_whisper_language(
|
||||
state: axum::extract::State<State>,
|
||||
Json(whisper_language): Json<UpdateWhisperLanguageRequest>,
|
||||
) -> Result<Json<ApiResponse<()>>, ApiError> {
|
||||
update_whisper_language(state.0, whisper_language.whisper_language)
|
||||
.await
|
||||
.expect("Failed to update whisper language");
|
||||
Ok(Json(ApiResponse::success(())))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateSubtitleSettingRequest {
|
||||
auto_subtitle: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateUserAgentRequest {
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
async fn handler_update_user_agent(
|
||||
state: axum::extract::State<State>,
|
||||
Json(user_agent): Json<UpdateUserAgentRequest>,
|
||||
) -> Result<Json<ApiResponse<()>>, ApiError> {
|
||||
update_user_agent(state.0, user_agent.user_agent)
|
||||
.await
|
||||
.expect("Failed to update user agent");
|
||||
Ok(Json(ApiResponse::success(())))
|
||||
}
|
||||
|
||||
async fn handler_update_subtitle_setting(
|
||||
state: axum::extract::State<State>,
|
||||
Json(subtitle_setting): Json<UpdateSubtitleSettingRequest>,
|
||||
@@ -417,13 +451,14 @@ async fn handler_get_recorder_list(
|
||||
struct AddRecorderRequest {
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
extra: String,
|
||||
}
|
||||
|
||||
async fn handler_add_recorder(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<AddRecorderRequest>,
|
||||
) -> Result<Json<ApiResponse<RecorderRow>>, ApiError> {
|
||||
let recorder = add_recorder(state.0, param.platform, param.room_id)
|
||||
let recorder = add_recorder(state.0, param.platform, param.room_id, param.extra)
|
||||
.await
|
||||
.expect("Failed to add recorder");
|
||||
Ok(Json(ApiResponse::success(recorder)))
|
||||
@@ -461,17 +496,26 @@ async fn handler_get_room_info(
|
||||
Ok(Json(ApiResponse::success(room_info)))
|
||||
}
|
||||
|
||||
async fn handler_get_archive_disk_usage(
|
||||
state: axum::extract::State<State>,
|
||||
) -> Result<Json<ApiResponse<u64>>, ApiError> {
|
||||
let disk_usage = get_archive_disk_usage(state.0).await?;
|
||||
Ok(Json(ApiResponse::success(disk_usage)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GetArchivesRequest {
|
||||
room_id: u64,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
}
|
||||
|
||||
async fn handler_get_archives(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<GetArchivesRequest>,
|
||||
) -> Result<Json<ApiResponse<Vec<RecordRow>>>, ApiError> {
|
||||
let archives = get_archives(state.0, param.room_id).await?;
|
||||
let archives = get_archives(state.0, param.room_id, param.offset, param.limit).await?;
|
||||
Ok(Json(ApiResponse::success(archives)))
|
||||
}
|
||||
|
||||
@@ -490,6 +534,40 @@ async fn handler_get_archive(
|
||||
Ok(Json(ApiResponse::success(archive)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GetArchiveSubtitleRequest {
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
}
|
||||
|
||||
async fn handler_get_archive_subtitle(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<GetArchiveSubtitleRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
let subtitle =
|
||||
get_archive_subtitle(state.0, param.platform, param.room_id, param.live_id).await?;
|
||||
Ok(Json(ApiResponse::success(subtitle)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GenerateArchiveSubtitleRequest {
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
}
|
||||
|
||||
async fn handler_generate_archive_subtitle(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<GenerateArchiveSubtitleRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
let subtitle =
|
||||
generate_archive_subtitle(state.0, param.platform, param.room_id, param.live_id).await?;
|
||||
Ok(Json(ApiResponse::success(subtitle)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeleteArchiveRequest {
|
||||
@@ -556,6 +634,7 @@ async fn handler_get_today_record_count(
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GetRecentRecordRequest {
|
||||
room_id: u64,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
}
|
||||
@@ -564,7 +643,8 @@ async fn handler_get_recent_record(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<GetRecentRecordRequest>,
|
||||
) -> Result<Json<ApiResponse<Vec<RecordRow>>>, ApiError> {
|
||||
let recent_record = get_recent_record(state.0, param.offset, param.limit).await?;
|
||||
let recent_record =
|
||||
get_recent_record(state.0, param.room_id, param.offset, param.limit).await?;
|
||||
Ok(Json(ApiResponse::success(recent_record)))
|
||||
}
|
||||
|
||||
@@ -726,6 +806,58 @@ async fn handler_update_video_cover(
|
||||
Ok(Json(ApiResponse::success(())))
|
||||
}
|
||||
|
||||
// 处理base64图片数据的API
|
||||
async fn handler_image_base64(
|
||||
Path(video_id): Path<i64>,
|
||||
state: axum::extract::State<State>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
// 获取视频封面
|
||||
let cover = match get_video_cover(state.0, video_id).await {
|
||||
Ok(cover) => cover,
|
||||
Err(_) => return Err(StatusCode::NOT_FOUND),
|
||||
};
|
||||
|
||||
// 检查是否是base64数据URL
|
||||
if cover.starts_with("data:image/") {
|
||||
if let Some(base64_start) = cover.find("base64,") {
|
||||
let base64_data = &cover[base64_start + 7..]; // 跳过 "base64,"
|
||||
|
||||
// 解码base64数据
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
if let Ok(image_data) = general_purpose::STANDARD.decode(base64_data) {
|
||||
// 确定MIME类型
|
||||
let content_type = if cover.contains("data:image/png") {
|
||||
"image/png"
|
||||
} else if cover.contains("data:image/jpeg") || cover.contains("data:image/jpg") {
|
||||
"image/jpeg"
|
||||
} else if cover.contains("data:image/gif") {
|
||||
"image/gif"
|
||||
} else if cover.contains("data:image/webp") {
|
||||
"image/webp"
|
||||
} else {
|
||||
"image/png" // 默认
|
||||
};
|
||||
|
||||
let mut response =
|
||||
axum::response::Response::new(axum::body::Body::from(image_data));
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
content_type.parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
"public, max-age=3600".parse().unwrap(),
|
||||
);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GenerateVideoSubtitleRequest {
|
||||
@@ -794,6 +926,74 @@ async fn handler_encode_video_subtitle(
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImportExternalVideoRequest {
|
||||
event_id: String,
|
||||
file_path: String,
|
||||
title: String,
|
||||
original_name: String,
|
||||
size: i64,
|
||||
room_id: u64,
|
||||
}
|
||||
|
||||
async fn handler_import_external_video(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<ImportExternalVideoRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
import_external_video(
|
||||
state.0,
|
||||
param.event_id.clone(),
|
||||
param.file_path.clone(),
|
||||
param.title,
|
||||
param.original_name,
|
||||
param.size,
|
||||
param.room_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success(param.event_id)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClipVideoRequest {
|
||||
event_id: String,
|
||||
parent_video_id: i64,
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
clip_title: String,
|
||||
}
|
||||
|
||||
async fn handler_clip_video(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<ClipVideoRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
clip_video(
|
||||
state.0,
|
||||
param.event_id.clone(),
|
||||
param.parent_video_id,
|
||||
param.start_time,
|
||||
param.end_time,
|
||||
param.clip_title,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success(param.event_id)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GetFileSizeRequest {
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
async fn handler_get_file_size(
|
||||
_state: axum::extract::State<State>,
|
||||
Json(param): Json<GetFileSizeRequest>,
|
||||
) -> Result<Json<ApiResponse<u64>>, ApiError> {
|
||||
let file_size = get_file_size(param.file_path).await?;
|
||||
Ok(Json(ApiResponse::success(file_size)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConsoleLogRequest {
|
||||
@@ -916,6 +1116,117 @@ async fn handler_get_tasks(
|
||||
Ok(Json(ApiResponse::success(tasks)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GenericFfmpegCommandRequest {
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
async fn handler_generic_ffmpeg_command(
|
||||
state: axum::extract::State<State>,
|
||||
Json(params): Json<GenericFfmpegCommandRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
let result = generic_ffmpeg_command(state.0, params.args).await?;
|
||||
Ok(Json(ApiResponse::success(result)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ListFolderRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn handler_list_folder(
|
||||
state: axum::extract::State<State>,
|
||||
Json(params): Json<ListFolderRequest>,
|
||||
) -> Result<Json<ApiResponse<Vec<String>>>, ApiError> {
|
||||
let result = list_folder(state.0, params.path).await?;
|
||||
Ok(Json(ApiResponse::success(result)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FileUploadResponse {
|
||||
file_path: String,
|
||||
file_name: String,
|
||||
file_size: u64,
|
||||
}
|
||||
|
||||
async fn handler_upload_file(
|
||||
state: axum::extract::State<State>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<FileUploadResponse>>, ApiError> {
|
||||
if state.readonly {
|
||||
return Err(ApiError("Server is in readonly mode".to_string()));
|
||||
}
|
||||
|
||||
let mut file_name = String::new();
|
||||
let mut file_data = Vec::new();
|
||||
let mut _room_id = 0u64;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| e.to_string())? {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
file_name = field.file_name().unwrap_or("unknown").to_string();
|
||||
file_data = field.bytes().await.map_err(|e| e.to_string())?.to_vec();
|
||||
}
|
||||
"roomId" => {
|
||||
let room_id_str = field.text().await.map_err(|e| e.to_string())?;
|
||||
_room_id = room_id_str.parse().unwrap_or(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if file_name.is_empty() || file_data.is_empty() {
|
||||
return Err(ApiError("No file uploaded".to_string()));
|
||||
}
|
||||
|
||||
// 创建上传目录
|
||||
let config = state.config.read().await;
|
||||
let upload_dir = std::path::Path::new(&config.cache).join("uploads");
|
||||
if !upload_dir.exists() {
|
||||
std::fs::create_dir_all(&upload_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// 生成唯一文件名避免冲突
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
let extension = std::path::Path::new(&file_name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
let base_name = std::path::Path::new(&file_name)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or("upload");
|
||||
|
||||
let unique_filename = if extension.is_empty() {
|
||||
format!("{}_{}", base_name, timestamp)
|
||||
} else {
|
||||
format!("{}_{}.{}", base_name, timestamp, extension)
|
||||
};
|
||||
|
||||
let file_path = upload_dir.join(&unique_filename);
|
||||
|
||||
// 写入文件
|
||||
tokio::fs::write(&file_path, &file_data)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let file_size = file_data.len() as u64;
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
log::info!("File uploaded: {} ({} bytes)", file_path_str, file_size);
|
||||
|
||||
Ok(Json(ApiResponse::success(FileUploadResponse {
|
||||
file_path: file_path_str,
|
||||
file_name: unique_filename,
|
||||
file_size,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn handler_hls(
|
||||
state: axum::extract::State<State>,
|
||||
Path(uri): Path<String>,
|
||||
@@ -1048,6 +1359,11 @@ async fn handler_output(
|
||||
Some("m4v") => "video/x-m4v",
|
||||
Some("mkv") => "video/x-matroska",
|
||||
Some("avi") => "video/x-msvideo",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
Some("svg") => "image/svg+xml",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
@@ -1059,14 +1375,28 @@ async fn handler_output(
|
||||
content_type.parse().unwrap(),
|
||||
);
|
||||
|
||||
// Add Content-Disposition header to force download
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// Only set Content-Disposition for non-media files to allow inline playback/display
|
||||
if !matches!(
|
||||
content_type,
|
||||
"video/mp4"
|
||||
| "video/webm"
|
||||
| "video/x-m4v"
|
||||
| "video/x-matroska"
|
||||
| "video/x-msvideo"
|
||||
| "image/jpeg"
|
||||
| "image/png"
|
||||
| "image/gif"
|
||||
| "image/webp"
|
||||
| "image/svg+xml"
|
||||
) {
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let content_length = end - start + 1;
|
||||
headers.insert(
|
||||
@@ -1150,6 +1480,8 @@ async fn handler_sse(
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_BODY_SIZE: usize = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
pub async fn start_api_server(state: State) {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
@@ -1194,6 +1526,14 @@ pub async fn start_api_server(state: State) {
|
||||
"/api/generate_video_subtitle",
|
||||
post(handler_generate_video_subtitle),
|
||||
)
|
||||
.route(
|
||||
"/api/generate_archive_subtitle",
|
||||
post(handler_generate_archive_subtitle),
|
||||
)
|
||||
.route(
|
||||
"/api/generic_ffmpeg_command",
|
||||
post(handler_generic_ffmpeg_command),
|
||||
)
|
||||
.route(
|
||||
"/api/update_video_subtitle",
|
||||
post(handler_update_video_subtitle),
|
||||
@@ -1203,6 +1543,11 @@ pub async fn start_api_server(state: State) {
|
||||
"/api/encode_video_subtitle",
|
||||
post(handler_encode_video_subtitle),
|
||||
)
|
||||
.route(
|
||||
"/api/import_external_video",
|
||||
post(handler_import_external_video),
|
||||
)
|
||||
.route("/api/clip_video", post(handler_clip_video))
|
||||
.route("/api/update_notify", post(handler_update_notify))
|
||||
.route(
|
||||
"/api/update_status_check_interval",
|
||||
@@ -1227,7 +1572,12 @@ pub async fn start_api_server(state: State) {
|
||||
.route(
|
||||
"/api/update_auto_generate",
|
||||
post(handler_update_auto_generate),
|
||||
);
|
||||
)
|
||||
.route(
|
||||
"/api/update_whisper_language",
|
||||
post(handler_update_whisper_language),
|
||||
)
|
||||
.route("/api/update_user_agent", post(handler_update_user_agent));
|
||||
} else {
|
||||
log::info!("Running in readonly mode, some api routes are disabled");
|
||||
}
|
||||
@@ -1244,6 +1594,14 @@ pub async fn start_api_server(state: State) {
|
||||
.route("/api/get_room_info", post(handler_get_room_info))
|
||||
.route("/api/get_archives", post(handler_get_archives))
|
||||
.route("/api/get_archive", post(handler_get_archive))
|
||||
.route(
|
||||
"/api/get_archive_disk_usage",
|
||||
post(handler_get_archive_disk_usage),
|
||||
)
|
||||
.route(
|
||||
"/api/get_archive_subtitle",
|
||||
post(handler_get_archive_subtitle),
|
||||
)
|
||||
.route("/api/get_danmu_record", post(handler_get_danmu_record))
|
||||
.route("/api/get_total_length", post(handler_get_total_length))
|
||||
.route(
|
||||
@@ -1259,25 +1617,42 @@ pub async fn start_api_server(state: State) {
|
||||
.route("/api/get_all_videos", post(handler_get_all_videos))
|
||||
.route("/api/get_video_typelist", post(handler_get_video_typelist))
|
||||
.route("/api/get_video_subtitle", post(handler_get_video_subtitle))
|
||||
.route("/api/get_file_size", post(handler_get_file_size))
|
||||
.route("/api/delete_task", post(handler_delete_task))
|
||||
.route("/api/get_tasks", post(handler_get_tasks))
|
||||
.route("/api/export_danmu", post(handler_export_danmu))
|
||||
// Utils commands
|
||||
.route("/api/get_disk_info", post(handler_get_disk_info))
|
||||
.route("/api/console_log", post(handler_console_log))
|
||||
.route("/api/list_folder", post(handler_list_folder))
|
||||
.route("/api/fetch", post(handler_fetch))
|
||||
.route("/api/upload_file", post(handler_upload_file))
|
||||
.route("/api/image/:video_id", get(handler_image_base64))
|
||||
.route("/hls/*uri", get(handler_hls))
|
||||
.route("/output/*uri", get(handler_output))
|
||||
.route("/api/sse", get(handler_sse));
|
||||
|
||||
let router = app
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(20 * 1024 * 1024))
|
||||
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
|
||||
.with_state(state);
|
||||
|
||||
let addr = "0.0.0.0:3000";
|
||||
log::info!("API server listening on http://{}", addr);
|
||||
log::info!("Starting API server on http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(listener) => {
|
||||
log::info!("API server listening on http://{}", addr);
|
||||
listener
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to bind to address {}: {}", addr, e);
|
||||
log::error!("Please check if the port is already in use or try a different port");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = axum::serve(listener, router).await {
|
||||
log::error!("Server error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use chrono::Utc;
|
||||
use config::Config;
|
||||
use database::Database;
|
||||
use recorder::bilibili::client::BiliClient;
|
||||
use recorder::PlatformType;
|
||||
use recorder_manager::RecorderManager;
|
||||
use simplelog::ConfigBuilder;
|
||||
use state::State;
|
||||
@@ -42,7 +43,6 @@ use std::os::windows::fs::MetadataExt;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use {
|
||||
recorder::PlatformType,
|
||||
tauri::{Manager, WindowEvent},
|
||||
tauri_plugin_sql::{Migration, MigrationKind},
|
||||
};
|
||||
@@ -117,6 +117,9 @@ async fn setup_logging(log_dir: &Path) -> Result<(), Box<dyn std::error::Error>>
|
||||
),
|
||||
])?;
|
||||
|
||||
// logging current package version
|
||||
log::info!("Current version: {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -155,6 +158,32 @@ fn get_migrations() -> Vec<Migration> {
|
||||
sql: r#"CREATE TABLE tasks (id TEXT PRIMARY KEY, type TEXT, status TEXT, message TEXT, metadata TEXT, created_at TEXT);"#,
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
// add id_str column to support string IDs like Douyin sec_uid while keeping uid for Bilibili compatibility
|
||||
Migration {
|
||||
version: 5,
|
||||
description: "add_id_str_column",
|
||||
sql: r#"ALTER TABLE accounts ADD COLUMN id_str TEXT;"#,
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
// add extra column to recorders
|
||||
Migration {
|
||||
version: 6,
|
||||
description: "add_extra_column_to_recorders",
|
||||
sql: r#"ALTER TABLE recorders ADD COLUMN extra TEXT;"#,
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
// add indexes
|
||||
Migration {
|
||||
version: 7,
|
||||
description: "add_indexes",
|
||||
sql: r#"
|
||||
CREATE INDEX idx_records_live_id ON records (room_id, live_id);
|
||||
CREATE INDEX idx_records_created_at ON records (room_id, created_at);
|
||||
CREATE INDEX idx_videos_room_id ON videos (room_id);
|
||||
CREATE INDEX idx_videos_created_at ON videos (created_at);
|
||||
"#,
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -202,7 +231,7 @@ async fn setup_server_state(args: Args) -> Result<State, Box<dyn std::error::Err
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let client = Arc::new(BiliClient::new()?);
|
||||
let client = Arc::new(BiliClient::new(&config.user_agent)?);
|
||||
let config = Arc::new(RwLock::new(config));
|
||||
let db = Arc::new(Database::new());
|
||||
// connect to sqlite database
|
||||
@@ -233,6 +262,63 @@ async fn setup_server_state(args: Args) -> Result<State, Box<dyn std::error::Err
|
||||
let progress_manager = Arc::new(ProgressManager::new());
|
||||
let emitter = EventEmitter::new(progress_manager.get_event_sender());
|
||||
let recorder_manager = Arc::new(RecorderManager::new(emitter, db.clone(), config.clone()));
|
||||
|
||||
// Update account infos for headless mode
|
||||
let accounts = db.get_accounts().await?;
|
||||
for account in accounts {
|
||||
let platform = PlatformType::from_str(&account.platform).unwrap();
|
||||
|
||||
if platform == PlatformType::BiliBili {
|
||||
match client.get_user_info(&account, account.uid).await {
|
||||
Ok(account_info) => {
|
||||
if let Err(e) = db
|
||||
.update_account(
|
||||
&account.platform,
|
||||
account_info.user_id,
|
||||
&account_info.user_name,
|
||||
&account_info.user_avatar_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Error when updating Bilibili account info {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Get Bilibili user info failed {}", e);
|
||||
}
|
||||
}
|
||||
} else if platform == PlatformType::Douyin {
|
||||
// Update Douyin account info
|
||||
use crate::recorder::douyin::client::DouyinClient;
|
||||
let douyin_client = DouyinClient::new(&config.read().await.user_agent, &account);
|
||||
match douyin_client.get_user_info().await {
|
||||
Ok(user_info) => {
|
||||
let avatar_url = user_info
|
||||
.avatar_thumb
|
||||
.url_list
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Err(e) = db
|
||||
.update_account_with_id_str(
|
||||
&account,
|
||||
&user_info.sec_uid,
|
||||
&user_info.nickname,
|
||||
&avatar_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Error when updating Douyin account info {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Get Douyin user info failed {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = try_rebuild_archives(&db, config.read().await.cache.clone().into()).await;
|
||||
|
||||
Ok(State {
|
||||
@@ -267,7 +353,7 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
|
||||
}
|
||||
};
|
||||
|
||||
let client = Arc::new(BiliClient::new()?);
|
||||
let client = Arc::new(BiliClient::new(&config.user_agent)?);
|
||||
let config = Arc::new(RwLock::new(config));
|
||||
let config_clone = config.clone();
|
||||
let dbs = app.state::<tauri_plugin_sql::DbInstances>().inner();
|
||||
@@ -304,28 +390,55 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
|
||||
|
||||
// update account infos
|
||||
for account in accounts {
|
||||
// only update bilibili account
|
||||
let platform = PlatformType::from_str(&account.platform).unwrap();
|
||||
if platform != PlatformType::BiliBili {
|
||||
continue;
|
||||
}
|
||||
|
||||
match client_clone.get_user_info(&account, account.uid).await {
|
||||
Ok(account_info) => {
|
||||
if let Err(e) = db_clone
|
||||
.update_account(
|
||||
&account.platform,
|
||||
account_info.user_id,
|
||||
&account_info.user_name,
|
||||
&account_info.user_avatar_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Error when updating account info {}", e);
|
||||
if platform == PlatformType::BiliBili {
|
||||
match client_clone.get_user_info(&account, account.uid).await {
|
||||
Ok(account_info) => {
|
||||
if let Err(e) = db_clone
|
||||
.update_account(
|
||||
&account.platform,
|
||||
account_info.user_id,
|
||||
&account_info.user_name,
|
||||
&account_info.user_avatar_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Error when updating Bilibili account info {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Get Bilibili user info failed {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Get user info failed {}", e);
|
||||
} else if platform == PlatformType::Douyin {
|
||||
// Update Douyin account info
|
||||
use crate::recorder::douyin::client::DouyinClient;
|
||||
let douyin_client = DouyinClient::new(&config_clone.read().await.user_agent, &account);
|
||||
match douyin_client.get_user_info().await {
|
||||
Ok(user_info) => {
|
||||
let avatar_url = user_info
|
||||
.avatar_thumb
|
||||
.url_list
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Err(e) = db_clone
|
||||
.update_account_with_id_str(
|
||||
&account,
|
||||
&user_info.sec_uid,
|
||||
&user_info.nickname,
|
||||
&avatar_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Error when updating Douyin account info {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Get Douyin user info failed {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,6 +522,8 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
|
||||
crate::handlers::config::update_openai_api_endpoint,
|
||||
crate::handlers::config::update_auto_generate,
|
||||
crate::handlers::config::update_status_check_interval,
|
||||
crate::handlers::config::update_whisper_language,
|
||||
crate::handlers::config::update_user_agent,
|
||||
crate::handlers::message::get_messages,
|
||||
crate::handlers::message::read_message,
|
||||
crate::handlers::message::delete_message,
|
||||
@@ -416,8 +531,11 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
|
||||
crate::handlers::recorder::add_recorder,
|
||||
crate::handlers::recorder::remove_recorder,
|
||||
crate::handlers::recorder::get_room_info,
|
||||
crate::handlers::recorder::get_archive_disk_usage,
|
||||
crate::handlers::recorder::get_archives,
|
||||
crate::handlers::recorder::get_archive,
|
||||
crate::handlers::recorder::get_archive_subtitle,
|
||||
crate::handlers::recorder::generate_archive_subtitle,
|
||||
crate::handlers::recorder::delete_archive,
|
||||
crate::handlers::recorder::get_danmu_record,
|
||||
crate::handlers::recorder::export_danmu,
|
||||
@@ -441,6 +559,10 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
|
||||
crate::handlers::video::get_video_subtitle,
|
||||
crate::handlers::video::update_video_subtitle,
|
||||
crate::handlers::video::encode_video_subtitle,
|
||||
crate::handlers::video::generic_ffmpeg_command,
|
||||
crate::handlers::video::import_external_video,
|
||||
crate::handlers::video::clip_video,
|
||||
crate::handlers::video::get_file_size,
|
||||
crate::handlers::task::get_tasks,
|
||||
crate::handlers::task::delete_task,
|
||||
crate::handlers::utils::show_in_folder,
|
||||
@@ -450,6 +572,7 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
|
||||
crate::handlers::utils::open_clip,
|
||||
crate::handlers::utils::open_log_folder,
|
||||
crate::handlers::utils::console_log,
|
||||
crate::handlers::utils::list_folder,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -457,7 +580,7 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = fix_path_env::fix();
|
||||
|
||||
let builder = tauri::Builder::default();
|
||||
let builder = tauri::Builder::default().plugin(tauri_plugin_deep_link::init());
|
||||
let builder = setup_plugins(builder);
|
||||
let builder = setup_event_handlers(builder);
|
||||
let builder = setup_invoke_handlers(builder);
|
||||
|
||||
@@ -81,6 +81,11 @@ pub trait Recorder: Send + Sync + 'static {
|
||||
async fn info(&self) -> RecorderInfo;
|
||||
async fn comments(&self, live_id: &str) -> Result<Vec<DanmuEntry>, errors::RecorderError>;
|
||||
async fn is_recording(&self, live_id: &str) -> bool;
|
||||
async fn get_archive_subtitle(&self, live_id: &str) -> Result<String, errors::RecorderError>;
|
||||
async fn generate_archive_subtitle(
|
||||
&self,
|
||||
live_id: &str,
|
||||
) -> Result<String, errors::RecorderError>;
|
||||
async fn enable(&self);
|
||||
async fn disable(&self);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ use super::entry::{EntryStore, Range};
|
||||
use super::errors::RecorderError;
|
||||
use super::PlatformType;
|
||||
use crate::database::account::AccountRow;
|
||||
use crate::ffmpeg::get_video_resolution;
|
||||
use crate::progress_manager::Event;
|
||||
use crate::progress_reporter::EventEmitter;
|
||||
use crate::recorder_manager::RecorderEvent;
|
||||
use crate::subtitle_generator::item_to_srt;
|
||||
|
||||
use super::danmu::{DanmuEntry, DanmuStorage};
|
||||
use super::entry::TsEntry;
|
||||
@@ -20,9 +22,11 @@ use danmu_stream::DanmuMessageType;
|
||||
use errors::BiliClientError;
|
||||
use m3u8_rs::{Playlist, QuotedOrUnquoted, VariantStream};
|
||||
use regex::Regex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use url::Url;
|
||||
@@ -57,16 +61,18 @@ pub struct BiliRecorder {
|
||||
cover: Arc<RwLock<Option<String>>>,
|
||||
entry_store: Arc<RwLock<Option<EntryStore>>>,
|
||||
is_recording: Arc<RwLock<bool>>,
|
||||
force_update: Arc<AtomicBool>,
|
||||
last_update: Arc<RwLock<i64>>,
|
||||
quit: Arc<Mutex<bool>>,
|
||||
live_stream: Arc<RwLock<Option<BiliStream>>>,
|
||||
danmu_storage: Arc<RwLock<Option<DanmuStorage>>>,
|
||||
live_end_channel: broadcast::Sender<RecorderEvent>,
|
||||
enabled: Arc<RwLock<bool>>,
|
||||
last_segment_offset: Arc<RwLock<Option<i64>>>, // 保存上次处理的最后一个片段的偏移
|
||||
current_header_info: Arc<RwLock<Option<HeaderInfo>>>, // 保存当前的分辨率
|
||||
|
||||
danmu_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
record_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
master_manifest: Arc<RwLock<Option<String>>>,
|
||||
}
|
||||
|
||||
impl From<DatabaseError> for super::errors::RecorderError {
|
||||
@@ -93,9 +99,15 @@ pub struct BiliRecorderOptions {
|
||||
pub channel: broadcast::Sender<RecorderEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HeaderInfo {
|
||||
url: String,
|
||||
resolution: String,
|
||||
}
|
||||
|
||||
impl BiliRecorder {
|
||||
pub async fn new(options: BiliRecorderOptions) -> Result<Self, super::errors::RecorderError> {
|
||||
let client = BiliClient::new()?;
|
||||
let client = BiliClient::new(&options.config.read().await.user_agent)?;
|
||||
let room_info = client
|
||||
.get_room_info(&options.account, options.room_id)
|
||||
.await?;
|
||||
@@ -130,15 +142,16 @@ impl BiliRecorder {
|
||||
live_id: Arc::new(RwLock::new(String::new())),
|
||||
cover: Arc::new(RwLock::new(cover)),
|
||||
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
|
||||
force_update: Arc::new(AtomicBool::new(false)),
|
||||
quit: Arc::new(Mutex::new(false)),
|
||||
live_stream: Arc::new(RwLock::new(None)),
|
||||
danmu_storage: Arc::new(RwLock::new(None)),
|
||||
live_end_channel: options.channel,
|
||||
enabled: Arc::new(RwLock::new(options.auto_start)),
|
||||
|
||||
last_segment_offset: Arc::new(RwLock::new(None)),
|
||||
current_header_info: Arc::new(RwLock::new(None)),
|
||||
danmu_task: Arc::new(Mutex::new(None)),
|
||||
record_task: Arc::new(Mutex::new(None)),
|
||||
master_manifest: Arc::new(RwLock::new(None)),
|
||||
};
|
||||
log::info!("Recorder for room {} created.", options.room_id);
|
||||
Ok(recorder)
|
||||
@@ -150,6 +163,8 @@ impl BiliRecorder {
|
||||
*self.live_stream.write().await = None;
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
*self.danmu_storage.write().await = None;
|
||||
*self.last_segment_offset.write().await = None;
|
||||
*self.current_header_info.write().await = None;
|
||||
}
|
||||
|
||||
async fn should_record(&self) -> bool {
|
||||
@@ -255,11 +270,13 @@ impl BiliRecorder {
|
||||
return true;
|
||||
}
|
||||
|
||||
let master_manifest =
|
||||
m3u8_rs::parse_playlist_res(master_manifest.as_ref().unwrap().as_bytes())
|
||||
.map_err(|_| super::errors::RecorderError::M3u8ParseFailed {
|
||||
content: master_manifest.as_ref().unwrap().clone(),
|
||||
});
|
||||
let master_manifest = master_manifest.unwrap();
|
||||
*self.master_manifest.write().await = Some(master_manifest.clone());
|
||||
|
||||
let master_manifest = m3u8_rs::parse_playlist_res(master_manifest.as_bytes())
|
||||
.map_err(|_| super::errors::RecorderError::M3u8ParseFailed {
|
||||
content: master_manifest.clone(),
|
||||
});
|
||||
if master_manifest.is_err() {
|
||||
log::error!(
|
||||
"[{}]Parse master manifest failed: {}",
|
||||
@@ -312,41 +329,27 @@ impl BiliRecorder {
|
||||
|
||||
let stream = new_stream.unwrap();
|
||||
|
||||
let should_update_stream = self.live_stream.read().await.is_none()
|
||||
|| !self
|
||||
.live_stream
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.is_same(&stream)
|
||||
|| self.force_update.load(Ordering::Relaxed);
|
||||
|
||||
if should_update_stream {
|
||||
log::info!(
|
||||
"[{}]Update to a new stream: {:?} => {}",
|
||||
let new_stream = self.fetch_real_stream(&stream).await;
|
||||
if new_stream.is_err() {
|
||||
log::error!(
|
||||
"[{}]Fetch real stream failed: {}",
|
||||
self.room_id,
|
||||
self.live_stream.read().await.clone(),
|
||||
stream
|
||||
new_stream.err().unwrap()
|
||||
);
|
||||
|
||||
self.force_update.store(false, Ordering::Relaxed);
|
||||
|
||||
let new_stream = self.fetch_real_stream(stream).await;
|
||||
if new_stream.is_err() {
|
||||
log::error!(
|
||||
"[{}]Fetch real stream failed: {}",
|
||||
self.room_id,
|
||||
new_stream.err().unwrap()
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
let new_stream = new_stream.unwrap();
|
||||
*self.live_stream.write().await = Some(new_stream);
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
return true;
|
||||
}
|
||||
|
||||
let new_stream = new_stream.unwrap();
|
||||
*self.live_stream.write().await = Some(new_stream);
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
|
||||
log::info!(
|
||||
"[{}]Update to a new stream: {:?} => {}",
|
||||
self.room_id,
|
||||
self.live_stream.read().await.clone(),
|
||||
stream
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -399,13 +402,14 @@ impl BiliRecorder {
|
||||
if let Ok(Some(msg)) = danmu_stream.recv().await {
|
||||
match msg {
|
||||
DanmuMessageType::DanmuMessage(danmu) => {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
self.emitter.emit(&Event::DanmuReceived {
|
||||
room: self.room_id,
|
||||
ts: danmu.timestamp,
|
||||
ts,
|
||||
content: danmu.message.clone(),
|
||||
});
|
||||
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
|
||||
storage.add_line(danmu.timestamp, &danmu.message).await;
|
||||
storage.add_line(ts, &danmu.message).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,6 +454,10 @@ impl BiliRecorder {
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed fetching index content from {}", stream.index());
|
||||
log::error!(
|
||||
"Master manifest: {}",
|
||||
self.master_manifest.read().await.as_ref().unwrap()
|
||||
);
|
||||
Err(super::errors::RecorderError::BiliClientError { err: e })
|
||||
}
|
||||
}
|
||||
@@ -461,6 +469,7 @@ impl BiliRecorder {
|
||||
return Err(super::errors::RecorderError::NoStreamAvailable);
|
||||
}
|
||||
let stream = stream.unwrap();
|
||||
|
||||
let index_content = self
|
||||
.client
|
||||
.read()
|
||||
@@ -475,6 +484,7 @@ impl BiliRecorder {
|
||||
url: stream.index(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut header_url = String::from("");
|
||||
let re = Regex::new(r"h.*\.m4s").unwrap();
|
||||
if let Some(captures) = re.captures(&index_content) {
|
||||
@@ -483,12 +493,24 @@ impl BiliRecorder {
|
||||
if header_url.is_empty() {
|
||||
log::warn!("Parse header url failed: {}", index_content);
|
||||
}
|
||||
|
||||
Ok(header_url)
|
||||
}
|
||||
|
||||
async fn get_resolution(
|
||||
&self,
|
||||
header_url: &str,
|
||||
) -> Result<String, super::errors::RecorderError> {
|
||||
log::debug!("Get resolution from {}", header_url);
|
||||
let resolution = get_video_resolution(header_url)
|
||||
.await
|
||||
.map_err(|e| super::errors::RecorderError::FfmpegError { err: e })?;
|
||||
Ok(resolution)
|
||||
}
|
||||
|
||||
async fn fetch_real_stream(
|
||||
&self,
|
||||
stream: BiliStream,
|
||||
stream: &BiliStream,
|
||||
) -> Result<BiliStream, super::errors::RecorderError> {
|
||||
let index_content = self
|
||||
.client
|
||||
@@ -497,16 +519,9 @@ impl BiliRecorder {
|
||||
.get_index_content(&self.account, &stream.index())
|
||||
.await?;
|
||||
if index_content.is_empty() {
|
||||
return Err(super::errors::RecorderError::InvalidStream { stream });
|
||||
}
|
||||
let index_content = self
|
||||
.client
|
||||
.read()
|
||||
.await
|
||||
.get_index_content(&self.account, &stream.index())
|
||||
.await?;
|
||||
if index_content.is_empty() {
|
||||
return Err(super::errors::RecorderError::InvalidStream { stream });
|
||||
return Err(super::errors::RecorderError::InvalidStream {
|
||||
stream: stream.clone(),
|
||||
});
|
||||
}
|
||||
if index_content.contains("Not Found") {
|
||||
return Err(super::errors::RecorderError::IndexNotFound {
|
||||
@@ -517,27 +532,23 @@ impl BiliRecorder {
|
||||
// this index content provides another m3u8 url
|
||||
// example: https://765b047cec3b099771d4b1851136046f.v.smtcdns.net/d1--cn-gotcha204-3.bilivideo.com/live-bvc/246284/live_1323355750_55526594/index.m3u8?expires=1741318366&len=0&oi=1961017843&pt=h5&qn=10000&trid=1007049a5300422eeffd2d6995d67b67ca5a&sigparams=cdn,expires,len,oi,pt,qn,trid&cdn=cn-gotcha204&sign=7ef1241439467ef27d3c804c1eda8d4d&site=1c89ef99adec13fab3a3592ee4db26d3&free_type=0&mid=475210&sche=ban&bvchls=1&trace=16&isp=ct&rg=East&pv=Shanghai&source=puv3_onetier&p2p_type=-1&score=1&suffix=origin&deploy_env=prod&flvsk=e5c4d6fb512ed7832b706f0a92f7a8c8&sk=246b3930727a89629f17520b1b551a2f&pp=rtmp&hot_cdn=57345&origin_bitrate=657300&sl=1&info_source=cache&vd=bc&src=puv3&order=1&TxLiveCode=cold_stream&TxDispType=3&svr_type=live_oc&tencent_test_client_ip=116.226.193.243&dispatch_from=OC_MGR61.170.74.11&utime=1741314857497
|
||||
let new_url = index_content.lines().last().unwrap();
|
||||
let base_url = new_url.split('/').next().unwrap();
|
||||
let host = base_url.split('/').next().unwrap();
|
||||
// extra is params after index.m3u8
|
||||
let extra = new_url.split(base_url).last().unwrap();
|
||||
let new_stream = BiliStream::new(StreamType::FMP4, base_url, host, extra);
|
||||
return Box::pin(self.fetch_real_stream(new_stream)).await;
|
||||
}
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
async fn extract_liveid(&self, header_url: &str) -> i64 {
|
||||
log::debug!("[{}]Extract liveid from {}", self.room_id, header_url);
|
||||
let re = Regex::new(r"h(\d+).m4s").unwrap();
|
||||
if let Some(cap) = re.captures(header_url) {
|
||||
let liveid: i64 = cap.get(1).unwrap().as_str().parse().unwrap();
|
||||
*self.live_id.write().await = liveid.to_string();
|
||||
liveid
|
||||
} else {
|
||||
log::error!("Extract liveid failed: {}", header_url);
|
||||
0
|
||||
// extract host: cn-gotcha204-3.bilivideo.com
|
||||
let host = new_url.split('/').nth(2).unwrap_or_default();
|
||||
let extra = new_url.split('?').nth(1).unwrap_or_default();
|
||||
// extract base url: live-bvc/246284/live_1323355750_55526594/
|
||||
let base_url = new_url
|
||||
.split('/')
|
||||
.skip(3)
|
||||
.take_while(|&part| !part.contains('?') && part != "index.m3u8")
|
||||
.collect::<Vec<&str>>()
|
||||
.join("/")
|
||||
+ "/";
|
||||
|
||||
let new_stream = BiliStream::new(StreamType::FMP4, base_url.as_str(), host, extra);
|
||||
return Box::pin(self.fetch_real_stream(&new_stream)).await;
|
||||
}
|
||||
Ok(stream.clone())
|
||||
}
|
||||
|
||||
async fn get_work_dir(&self, live_id: &str) -> String {
|
||||
@@ -557,8 +568,23 @@ impl BiliRecorder {
|
||||
}
|
||||
let current_stream = current_stream.unwrap();
|
||||
let parsed = self.get_playlist().await;
|
||||
if parsed.is_err() {
|
||||
return Err(parsed.err().unwrap());
|
||||
}
|
||||
|
||||
let playlist = parsed.unwrap();
|
||||
|
||||
let mut timestamp: i64 = self.live_id.read().await.parse::<i64>().unwrap_or(0);
|
||||
let mut work_dir = self.get_work_dir(timestamp.to_string().as_str()).await;
|
||||
let mut work_dir;
|
||||
let mut is_first_record = false;
|
||||
|
||||
// Get url from EXT-X-MAP
|
||||
let header_url = self.get_header_url().await?;
|
||||
if header_url.is_empty() {
|
||||
return Err(super::errors::RecorderError::EmptyHeader);
|
||||
}
|
||||
let full_header_url = current_stream.ts_url(&header_url);
|
||||
|
||||
// Check header if None
|
||||
if (self.entry_store.read().await.as_ref().is_none()
|
||||
|| self
|
||||
@@ -571,36 +597,11 @@ impl BiliRecorder {
|
||||
.is_none())
|
||||
&& current_stream.format == StreamType::FMP4
|
||||
{
|
||||
// Get url from EXT-X-MAP
|
||||
let header_url = self.get_header_url().await?;
|
||||
if header_url.is_empty() {
|
||||
return Err(super::errors::RecorderError::EmptyHeader);
|
||||
}
|
||||
timestamp = self.extract_liveid(&header_url).await;
|
||||
if timestamp == 0 {
|
||||
log::error!("[{}]Parse timestamp failed: {}", self.room_id, header_url);
|
||||
return Err(super::errors::RecorderError::InvalidTimestamp);
|
||||
}
|
||||
self.db
|
||||
.add_record(
|
||||
PlatformType::BiliBili,
|
||||
timestamp.to_string().as_str(),
|
||||
self.room_id,
|
||||
&self.room_info.read().await.room_title,
|
||||
self.cover.read().await.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
// now work dir is confirmed
|
||||
timestamp = Utc::now().timestamp_millis();
|
||||
*self.live_id.write().await = timestamp.to_string();
|
||||
work_dir = self.get_work_dir(timestamp.to_string().as_str()).await;
|
||||
is_first_record = true;
|
||||
|
||||
let entry_store = EntryStore::new(&work_dir).await;
|
||||
*self.entry_store.write().await = Some(entry_store);
|
||||
|
||||
// danmau file
|
||||
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
|
||||
*self.danmu_storage.write().await = DanmuStorage::new(&danmu_file_path).await;
|
||||
let full_header_url = current_stream.ts_url(&header_url);
|
||||
let file_name = header_url.split('/').next_back().unwrap();
|
||||
let mut header = TsEntry {
|
||||
url: file_name.to_string(),
|
||||
@@ -610,6 +611,12 @@ impl BiliRecorder {
|
||||
ts: timestamp,
|
||||
is_header: true,
|
||||
};
|
||||
|
||||
// Create work directory before download
|
||||
tokio::fs::create_dir_all(&work_dir)
|
||||
.await
|
||||
.map_err(|e| super::errors::RecorderError::IoError { err: e })?;
|
||||
|
||||
// Download header
|
||||
match self
|
||||
.client
|
||||
@@ -619,7 +626,41 @@ impl BiliRecorder {
|
||||
.await
|
||||
{
|
||||
Ok(size) => {
|
||||
if size == 0 {
|
||||
log::error!("Download header failed: {}", full_header_url);
|
||||
// Clean up empty directory since header download failed
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await {
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
return Err(super::errors::RecorderError::InvalidStream {
|
||||
stream: current_stream,
|
||||
});
|
||||
}
|
||||
header.size = size;
|
||||
|
||||
// Now that download succeeded, create the record and setup stores
|
||||
self.db
|
||||
.add_record(
|
||||
PlatformType::BiliBili,
|
||||
timestamp.to_string().as_str(),
|
||||
self.room_id,
|
||||
&self.room_info.read().await.room_title,
|
||||
self.cover.read().await.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let entry_store = EntryStore::new(&work_dir).await;
|
||||
*self.entry_store.write().await = Some(entry_store);
|
||||
|
||||
// danmu file
|
||||
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
|
||||
*self.danmu_storage.write().await = DanmuStorage::new(&danmu_file_path).await;
|
||||
|
||||
self.entry_store
|
||||
.write()
|
||||
.await
|
||||
@@ -627,68 +668,200 @@ impl BiliRecorder {
|
||||
.unwrap()
|
||||
.add_entry(header)
|
||||
.await;
|
||||
|
||||
let new_resolution = self.get_resolution(&full_header_url).await?;
|
||||
|
||||
log::info!(
|
||||
"[{}] Initial header resolution: {} {}",
|
||||
self.room_id,
|
||||
header_url,
|
||||
new_resolution
|
||||
);
|
||||
|
||||
*self.current_header_info.write().await = Some(HeaderInfo {
|
||||
url: header_url.clone(),
|
||||
resolution: new_resolution,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Download header failed: {}", e);
|
||||
// Clean up empty directory since header download failed
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await {
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
work_dir = self.get_work_dir(timestamp.to_string().as_str()).await;
|
||||
// For non-FMP4 streams, check if we need to initialize
|
||||
if self.entry_store.read().await.as_ref().is_none() {
|
||||
timestamp = Utc::now().timestamp_millis();
|
||||
*self.live_id.write().await = timestamp.to_string();
|
||||
work_dir = self.get_work_dir(timestamp.to_string().as_str()).await;
|
||||
is_first_record = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check resolution change
|
||||
let current_header_info = self.current_header_info.read().await.clone();
|
||||
if current_header_info.is_some() {
|
||||
let current_header_info = current_header_info.unwrap();
|
||||
if current_header_info.url != header_url {
|
||||
let new_resolution = self.get_resolution(&full_header_url).await?;
|
||||
log::debug!(
|
||||
"[{}] Header url changed: {} => {}, resolution: {} => {}",
|
||||
self.room_id,
|
||||
current_header_info.url,
|
||||
header_url,
|
||||
current_header_info.resolution,
|
||||
new_resolution
|
||||
);
|
||||
if current_header_info.resolution != new_resolution {
|
||||
self.reset().await;
|
||||
|
||||
return Err(super::errors::RecorderError::ResolutionChanged {
|
||||
err: format!(
|
||||
"Resolution changed: {} => {}",
|
||||
current_header_info.resolution, new_resolution
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
match parsed {
|
||||
Ok(Playlist::MasterPlaylist(pl)) => log::debug!("Master playlist:\n{:?}", pl),
|
||||
Ok(Playlist::MediaPlaylist(pl)) => {
|
||||
|
||||
match playlist {
|
||||
Playlist::MasterPlaylist(pl) => log::debug!("Master playlist:\n{:?}", pl),
|
||||
Playlist::MediaPlaylist(pl) => {
|
||||
let mut new_segment_fetched = false;
|
||||
let mut sequence = pl.media_sequence;
|
||||
let last_sequence = self
|
||||
.entry_store
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.last_sequence();
|
||||
for ts in pl.segments {
|
||||
if sequence <= last_sequence {
|
||||
sequence += 1;
|
||||
continue;
|
||||
}
|
||||
new_segment_fetched = true;
|
||||
.map(|store| store.last_sequence)
|
||||
.unwrap_or(0); // For first-time recording, start from 0
|
||||
|
||||
// Parse BILI-AUX offsets to calculate precise durations for FMP4
|
||||
let mut segment_offsets = Vec::new();
|
||||
for ts in pl.segments.iter() {
|
||||
let mut seg_offset: i64 = 0;
|
||||
for tag in ts.unknown_tags {
|
||||
for tag in &ts.unknown_tags {
|
||||
if tag.tag == "BILI-AUX" {
|
||||
if let Some(rest) = tag.rest {
|
||||
if let Some(rest) = &tag.rest {
|
||||
let parts: Vec<&str> = rest.split('|').collect();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
if !parts.is_empty() {
|
||||
let offset_hex = parts.first().unwrap();
|
||||
if let Ok(offset) = i64::from_str_radix(offset_hex, 16) {
|
||||
seg_offset = offset;
|
||||
}
|
||||
}
|
||||
let offset_hex = parts.first().unwrap().to_string();
|
||||
seg_offset = i64::from_str_radix(&offset_hex, 16).unwrap();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
segment_offsets.push(seg_offset);
|
||||
}
|
||||
|
||||
// Extract stream start timestamp from header if available for FMP4
|
||||
let stream_start_timestamp = self.room_info.read().await.live_start_time;
|
||||
|
||||
// Get the last segment offset from previous processing
|
||||
let mut last_offset = *self.last_segment_offset.read().await;
|
||||
|
||||
for (i, ts) in pl.segments.iter().enumerate() {
|
||||
let sequence = pl.media_sequence + i as u64;
|
||||
if sequence <= last_sequence {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts_url = current_stream.ts_url(&ts.uri);
|
||||
if Url::parse(&ts_url).is_err() {
|
||||
log::error!("Ts url is invalid. ts_url={} original={}", ts_url, ts.uri);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate precise timestamp from stream start + BILI-AUX offset for FMP4
|
||||
let ts_mili = if current_stream.format == StreamType::FMP4
|
||||
&& stream_start_timestamp > 0
|
||||
&& i < segment_offsets.len()
|
||||
{
|
||||
let seg_offset = segment_offsets[i];
|
||||
|
||||
stream_start_timestamp * 1000 + seg_offset
|
||||
} else {
|
||||
// Fallback to current time if parsing fails or not FMP4
|
||||
Utc::now().timestamp_millis()
|
||||
};
|
||||
|
||||
// encode segment offset into filename
|
||||
let file_name = ts.uri.split('/').next_back().unwrap_or(&ts.uri);
|
||||
let mut ts_length = pl.target_duration as f64;
|
||||
let ts = timestamp * 1000 + seg_offset;
|
||||
// calculate entry length using offset
|
||||
// the default #EXTINF is 1.0, which is not accurate
|
||||
if let Some(last) = self.entry_store.read().await.as_ref().unwrap().last_ts() {
|
||||
// skip this entry as it is already in cache or stream changed
|
||||
if ts <= last {
|
||||
continue;
|
||||
}
|
||||
ts_length = (ts - last) as f64 / 1000.0;
|
||||
}
|
||||
let ts_length = pl.target_duration as f64;
|
||||
|
||||
// Calculate precise duration from BILI-AUX offsets for FMP4
|
||||
let precise_length_from_aux =
|
||||
if current_stream.format == StreamType::FMP4 && i < segment_offsets.len() {
|
||||
let current_offset = segment_offsets[i];
|
||||
|
||||
// Get the previous offset for duration calculation
|
||||
let prev_offset = if i > 0 {
|
||||
// Use previous segment in current M3U8
|
||||
Some(segment_offsets[i - 1])
|
||||
} else {
|
||||
// Use saved last offset from previous M3U8 processing
|
||||
last_offset
|
||||
};
|
||||
|
||||
if let Some(prev) = prev_offset {
|
||||
let duration_ms = current_offset - prev;
|
||||
if duration_ms > 0 {
|
||||
Some(duration_ms as f64 / 1000.0) // Convert ms to seconds
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
// No previous offset available, use target duration
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let client = self.client.clone();
|
||||
let mut retry = 0;
|
||||
let mut work_dir_created_for_non_fmp4 = false;
|
||||
|
||||
// For non-FMP4 streams, create record on first successful ts download
|
||||
if is_first_record && current_stream.format != StreamType::FMP4 {
|
||||
// Create work directory before first ts download
|
||||
tokio::fs::create_dir_all(&work_dir)
|
||||
.await
|
||||
.map_err(|e| super::errors::RecorderError::IoError { err: e })?;
|
||||
work_dir_created_for_non_fmp4 = true;
|
||||
}
|
||||
|
||||
loop {
|
||||
if retry > 3 {
|
||||
log::error!("Download ts failed after retry");
|
||||
|
||||
// Clean up empty directory if first ts download failed for non-FMP4
|
||||
if is_first_record
|
||||
&& current_stream.format != StreamType::FMP4
|
||||
&& work_dir_created_for_non_fmp4
|
||||
{
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
match client
|
||||
@@ -700,11 +873,84 @@ impl BiliRecorder {
|
||||
Ok(size) => {
|
||||
if size == 0 {
|
||||
log::error!("Segment with size 0, stream might be corrupted");
|
||||
|
||||
// Clean up empty directory if first ts download failed for non-FMP4
|
||||
if is_first_record
|
||||
&& current_stream.format != StreamType::FMP4
|
||||
&& work_dir_created_for_non_fmp4
|
||||
{
|
||||
if let Err(cleanup_err) =
|
||||
tokio::fs::remove_dir_all(&work_dir).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Err(super::errors::RecorderError::InvalidStream {
|
||||
stream: current_stream,
|
||||
});
|
||||
}
|
||||
|
||||
// Create record and setup stores on first successful download for non-FMP4
|
||||
if is_first_record && current_stream.format != StreamType::FMP4 {
|
||||
self.db
|
||||
.add_record(
|
||||
PlatformType::BiliBili,
|
||||
timestamp.to_string().as_str(),
|
||||
self.room_id,
|
||||
&self.room_info.read().await.room_title,
|
||||
self.cover.read().await.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let entry_store = EntryStore::new(&work_dir).await;
|
||||
*self.entry_store.write().await = Some(entry_store);
|
||||
|
||||
// danmu file
|
||||
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
|
||||
*self.danmu_storage.write().await =
|
||||
DanmuStorage::new(&danmu_file_path).await;
|
||||
|
||||
is_first_record = false;
|
||||
}
|
||||
|
||||
// Get precise duration - prioritize BILI-AUX for FMP4, fallback to ffprobe if needed
|
||||
let precise_length = if let Some(aux_duration) =
|
||||
precise_length_from_aux
|
||||
{
|
||||
aux_duration
|
||||
} else if current_stream.format != StreamType::FMP4 {
|
||||
// For regular TS segments, use direct ffprobe
|
||||
let file_path = format!("{}/{}", work_dir, file_name);
|
||||
match crate::ffmpeg::get_segment_duration(std::path::Path::new(
|
||||
&file_path,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(duration) => {
|
||||
log::debug!(
|
||||
"Precise TS segment duration: {}s (original: {}s)",
|
||||
duration,
|
||||
ts_length
|
||||
);
|
||||
duration
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get precise TS duration for {}: {}, using fallback", file_name, e);
|
||||
ts_length
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// FMP4 segment without BILI-AUX info, use fallback
|
||||
log::debug!("No BILI-AUX data available for FMP4 segment {}, using target duration", file_name);
|
||||
ts_length
|
||||
};
|
||||
|
||||
self.entry_store
|
||||
.write()
|
||||
.await
|
||||
@@ -713,26 +959,56 @@ impl BiliRecorder {
|
||||
.add_entry(TsEntry {
|
||||
url: file_name.into(),
|
||||
sequence,
|
||||
length: ts_length,
|
||||
length: precise_length,
|
||||
size,
|
||||
ts,
|
||||
ts: ts_mili,
|
||||
is_header: false,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Update last offset for next segment calculation
|
||||
if current_stream.format == StreamType::FMP4
|
||||
&& i < segment_offsets.len()
|
||||
{
|
||||
last_offset = Some(segment_offsets[i]);
|
||||
}
|
||||
|
||||
new_segment_fetched = true;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
retry += 1;
|
||||
log::warn!("Download ts failed, retry {}: {}", retry, e);
|
||||
|
||||
// If this is the last retry and it's the first record for non-FMP4, clean up
|
||||
if retry > 3
|
||||
&& is_first_record
|
||||
&& current_stream.format != StreamType::FMP4
|
||||
&& work_dir_created_for_non_fmp4
|
||||
{
|
||||
if let Err(cleanup_err) =
|
||||
tokio::fs::remove_dir_all(&work_dir).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sequence += 1;
|
||||
}
|
||||
|
||||
if new_segment_fetched {
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
|
||||
// Save the last offset for next M3U8 processing
|
||||
if current_stream.format == StreamType::FMP4 {
|
||||
*self.last_segment_offset.write().await = last_offset;
|
||||
}
|
||||
|
||||
self.db
|
||||
.update_record(
|
||||
timestamp.to_string().as_str(),
|
||||
@@ -755,19 +1031,17 @@ impl BiliRecorder {
|
||||
}
|
||||
}
|
||||
// check the current stream is too slow or not
|
||||
if let Some(last_ts) = self.entry_store.read().await.as_ref().unwrap().last_ts() {
|
||||
if last_ts < Utc::now().timestamp() - 10 {
|
||||
log::error!("Stream is too slow, last entry ts is at {}", last_ts);
|
||||
return Err(super::errors::RecorderError::SlowStream {
|
||||
stream: current_stream,
|
||||
});
|
||||
if let Some(entry_store) = self.entry_store.read().await.as_ref() {
|
||||
if let Some(last_ts) = entry_store.last_ts() {
|
||||
if last_ts < Utc::now().timestamp() - 10 {
|
||||
log::error!("Stream is too slow, last entry ts is at {}", last_ts);
|
||||
return Err(super::errors::RecorderError::SlowStream {
|
||||
stream: current_stream,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.force_update.store(true, Ordering::Relaxed);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// check stream is nearly expired
|
||||
@@ -780,8 +1054,7 @@ impl BiliRecorder {
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.expire - Utc::now().timestamp() < pre_offset as i64)
|
||||
{
|
||||
log::info!("Stream is nearly expired, force update");
|
||||
self.force_update.store(true, Ordering::Relaxed);
|
||||
log::info!("Stream is nearly expired");
|
||||
return Err(super::errors::RecorderError::StreamExpired {
|
||||
stream: current_stream.unwrap(),
|
||||
});
|
||||
@@ -816,11 +1089,12 @@ impl BiliRecorder {
|
||||
None
|
||||
};
|
||||
|
||||
self.entry_store.read().await.as_ref().unwrap().manifest(
|
||||
!live_status || range.is_some(),
|
||||
true,
|
||||
range,
|
||||
)
|
||||
if let Some(entry_store) = self.entry_store.read().await.as_ref() {
|
||||
entry_store.manifest(!live_status || range.is_some(), true, range)
|
||||
} else {
|
||||
// Return empty manifest if entry_store is not initialized yet
|
||||
"#EXTM3U\n#EXT-X-VERSION:3\n".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +1141,10 @@ impl super::Recorder for BiliRecorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// whatever error happened during update entries, reset to start another recording.
|
||||
*self_clone.is_recording.write().await = false;
|
||||
self_clone.reset().await;
|
||||
// go check status again after random 2-5 secs
|
||||
let secs = rand::random::<u64>() % 4 + 2;
|
||||
tokio::time::sleep(Duration::from_secs(
|
||||
@@ -966,7 +1243,11 @@ impl super::Recorder for BiliRecorder {
|
||||
Ok(if live_id == *self.live_id.read().await {
|
||||
// just return current cache content
|
||||
match self.danmu_storage.read().await.as_ref() {
|
||||
Some(storage) => storage.get_entries().await,
|
||||
Some(storage) => {
|
||||
storage
|
||||
.get_entries(self.first_segment_ts(live_id).await)
|
||||
.await
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
@@ -984,7 +1265,9 @@ impl super::Recorder for BiliRecorder {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let storage = storage.unwrap();
|
||||
storage.get_entries().await
|
||||
storage
|
||||
.get_entries(self.first_segment_ts(live_id).await)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
@@ -992,6 +1275,92 @@ impl super::Recorder for BiliRecorder {
|
||||
*self.live_id.read().await == live_id && *self.live_status.read().await
|
||||
}
|
||||
|
||||
async fn get_archive_subtitle(
|
||||
&self,
|
||||
live_id: &str,
|
||||
) -> Result<String, super::errors::RecorderError> {
|
||||
// read subtitle file under work_dir
|
||||
let work_dir = self.get_work_dir(live_id).await;
|
||||
let subtitle_file_path = format!("{}/{}", work_dir, "subtitle.srt");
|
||||
let subtitle_file = File::open(subtitle_file_path).await;
|
||||
if subtitle_file.is_err() {
|
||||
return Err(super::errors::RecorderError::SubtitleNotFound {
|
||||
live_id: live_id.to_string(),
|
||||
});
|
||||
}
|
||||
let subtitle_file = subtitle_file.unwrap();
|
||||
let mut subtitle_file = BufReader::new(subtitle_file);
|
||||
let mut subtitle_content = String::new();
|
||||
subtitle_file.read_to_string(&mut subtitle_content).await?;
|
||||
Ok(subtitle_content)
|
||||
}
|
||||
|
||||
async fn generate_archive_subtitle(
|
||||
&self,
|
||||
live_id: &str,
|
||||
) -> Result<String, super::errors::RecorderError> {
|
||||
// generate subtitle file under work_dir
|
||||
let work_dir = self.get_work_dir(live_id).await;
|
||||
let subtitle_file_path = format!("{}/{}", work_dir, "subtitle.srt");
|
||||
let mut subtitle_file = File::create(subtitle_file_path).await?;
|
||||
// first generate a tmp clip file
|
||||
// generate a tmp m3u8 index file
|
||||
let m3u8_index_file_path = format!("{}/{}", work_dir, "tmp.m3u8");
|
||||
let m3u8_content = self.m3u8_content(live_id, 0, 0).await;
|
||||
tokio::fs::write(&m3u8_index_file_path, m3u8_content).await?;
|
||||
log::info!("M3U8 index file generated: {}", m3u8_index_file_path);
|
||||
// generate a tmp clip file
|
||||
let clip_file_path = format!("{}/{}", work_dir, "tmp.mp4");
|
||||
if let Err(e) = crate::ffmpeg::clip_from_m3u8(
|
||||
None::<&crate::progress_reporter::ProgressReporter>,
|
||||
Path::new(&m3u8_index_file_path),
|
||||
Path::new(&clip_file_path),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(super::errors::RecorderError::SubtitleGenerationFailed {
|
||||
error: e.to_string(),
|
||||
});
|
||||
}
|
||||
log::info!("Temp clip file generated: {}", clip_file_path);
|
||||
// generate subtitle file
|
||||
let config = self.config.read().await;
|
||||
let result = crate::ffmpeg::generate_video_subtitle(
|
||||
None,
|
||||
Path::new(&clip_file_path),
|
||||
"whisper",
|
||||
&config.whisper_model,
|
||||
&config.whisper_prompt,
|
||||
&config.openai_api_key,
|
||||
&config.openai_api_endpoint,
|
||||
&config.whisper_language,
|
||||
)
|
||||
.await;
|
||||
// write subtitle file
|
||||
if let Err(e) = result {
|
||||
return Err(super::errors::RecorderError::SubtitleGenerationFailed {
|
||||
error: e.to_string(),
|
||||
});
|
||||
}
|
||||
log::info!("Subtitle generated");
|
||||
let result = result.unwrap();
|
||||
let subtitle_content = result
|
||||
.subtitle_content
|
||||
.iter()
|
||||
.map(item_to_srt)
|
||||
.collect::<Vec<String>>()
|
||||
.join("");
|
||||
subtitle_file.write_all(subtitle_content.as_bytes()).await?;
|
||||
log::info!("Subtitle file written");
|
||||
// remove tmp file
|
||||
tokio::fs::remove_file(&m3u8_index_file_path).await?;
|
||||
tokio::fs::remove_file(&clip_file_path).await?;
|
||||
log::info!("Tmp file removed");
|
||||
Ok(subtitle_content)
|
||||
}
|
||||
|
||||
async fn enable(&self) {
|
||||
*self.enabled.write().await = true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::database::account::AccountRow;
|
||||
use crate::progress_reporter::ProgressReporter;
|
||||
use crate::progress_reporter::ProgressReporterTrait;
|
||||
use base64::Engine;
|
||||
use chrono::TimeZone;
|
||||
use pct_str::PctString;
|
||||
use pct_str::URIReserved;
|
||||
use regex::Regex;
|
||||
@@ -42,6 +43,7 @@ pub struct RoomInfo {
|
||||
pub room_keyframe_url: String,
|
||||
pub room_title: String,
|
||||
pub user_id: u64,
|
||||
pub live_start_time: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
@@ -138,25 +140,12 @@ impl BiliStream {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_same(&self, other: &BiliStream) -> bool {
|
||||
// Extract live_id part from path (e.g., live_1848752274_71463808)
|
||||
let get_live_id = |path: &str| {
|
||||
path.split('/')
|
||||
.find(|part| part.starts_with("live_"))
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
let self_live_id = get_live_id(&self.path);
|
||||
let other_live_id = get_live_id(&other.path);
|
||||
self_live_id == other_live_id
|
||||
}
|
||||
}
|
||||
|
||||
impl BiliClient {
|
||||
pub fn new() -> Result<BiliClient, BiliClientError> {
|
||||
pub fn new(user_agent: &str) -> Result<BiliClient, BiliClientError> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36".parse().unwrap());
|
||||
headers.insert("user-agent", user_agent.parse().unwrap());
|
||||
|
||||
if let Ok(client) = Client::builder().timeout(Duration::from_secs(10)).build() {
|
||||
Ok(BiliClient { client, headers })
|
||||
@@ -214,7 +203,11 @@ impl BiliClient {
|
||||
pub async fn logout(&self, account: &AccountRow) -> Result<(), BiliClientError> {
|
||||
let url = "https://passport.bilibili.com/login/exit/v2";
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let params = [("csrf", account.csrf.clone())];
|
||||
let _ = self
|
||||
.client
|
||||
@@ -241,7 +234,11 @@ impl BiliClient {
|
||||
});
|
||||
let params = self.get_sign(params).await?;
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
@@ -283,7 +280,11 @@ impl BiliClient {
|
||||
room_id: u64,
|
||||
) -> Result<RoomInfo, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let response = self
|
||||
.client
|
||||
.get(format!(
|
||||
@@ -332,6 +333,22 @@ impl BiliClient {
|
||||
let live_status = res["data"]["live_status"]
|
||||
.as_u64()
|
||||
.ok_or(BiliClientError::InvalidValue)? as u8;
|
||||
// "live_time": "2025-08-09 18:33:35",
|
||||
let live_start_time_str = res["data"]["live_time"]
|
||||
.as_str()
|
||||
.ok_or(BiliClientError::InvalidValue)?;
|
||||
let live_start_time = if live_start_time_str == "0000-00-00 00:00:00" {
|
||||
0
|
||||
} else {
|
||||
let naive =
|
||||
chrono::NaiveDateTime::parse_from_str(live_start_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|_| BiliClientError::InvalidValue)?;
|
||||
chrono::Local
|
||||
.from_local_datetime(&naive)
|
||||
.earliest()
|
||||
.ok_or(BiliClientError::InvalidValue)?
|
||||
.timestamp()
|
||||
};
|
||||
Ok(RoomInfo {
|
||||
room_id,
|
||||
room_title,
|
||||
@@ -339,6 +356,7 @@ impl BiliClient {
|
||||
room_keyframe_url,
|
||||
user_id,
|
||||
live_status,
|
||||
live_start_time,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,7 +377,11 @@ impl BiliClient {
|
||||
url: &String,
|
||||
) -> Result<String, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let response = self
|
||||
.client
|
||||
.get(url.to_owned())
|
||||
@@ -476,7 +498,11 @@ impl BiliClient {
|
||||
video_file: &Path,
|
||||
) -> Result<PreuploadResponse, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let url = format!(
|
||||
"https://member.bilibili.com/preupload?name={}&r=upos&profile=ugcfx/bup",
|
||||
video_file.file_name().unwrap().to_str().unwrap()
|
||||
@@ -715,7 +741,11 @@ impl BiliClient {
|
||||
video: &profile::Video,
|
||||
) -> Result<VideoSubmitData, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let url = format!(
|
||||
"https://member.bilibili.com/x/vu/web/add/v3?ts={}&csrf={}",
|
||||
chrono::Local::now().timestamp(),
|
||||
@@ -761,7 +791,11 @@ impl BiliClient {
|
||||
chrono::Local::now().timestamp(),
|
||||
);
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let params = [("csrf", account.csrf.clone()), ("cover", cover.to_string())];
|
||||
match self
|
||||
.client
|
||||
@@ -799,7 +833,11 @@ impl BiliClient {
|
||||
) -> Result<(), BiliClientError> {
|
||||
let url = "https://api.live.bilibili.com/msg/send".to_string();
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let params = [
|
||||
("bubble", "0"),
|
||||
("msg", message),
|
||||
@@ -829,7 +867,11 @@ impl BiliClient {
|
||||
) -> Result<Vec<response::Typelist>, BiliClientError> {
|
||||
let url = "https://member.bilibili.com/x/vupre/web/archive/pre?lang=cn";
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
if let Ok(cookies) = account.cookies.parse() {
|
||||
headers.insert("cookie", cookies);
|
||||
} else {
|
||||
return Err(BiliClientError::InvalidCookie);
|
||||
}
|
||||
let resp: GeneralResponse = self
|
||||
.client
|
||||
.get(url)
|
||||
|
||||
@@ -10,6 +10,7 @@ custom_error! {pub BiliClientError
|
||||
InvalidUrl = "Invalid url",
|
||||
InvalidFormat = "Invalid stream format",
|
||||
InvalidStream = "Invalid stream",
|
||||
InvalidCookie = "Invalid cookie",
|
||||
UploadError{err: String} = "Upload error: {err}",
|
||||
UploadCancelled = "Upload was cancelled by user",
|
||||
EmptyCache = "Empty cache",
|
||||
|
||||
@@ -65,7 +65,20 @@ impl DanmuStorage {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn get_entries(&self) -> Vec<DanmuEntry> {
|
||||
self.cache.read().await.clone()
|
||||
// get entries with ts relative to live start time
|
||||
pub async fn get_entries(&self, live_start_ts: i64) -> Vec<DanmuEntry> {
|
||||
let mut danmus: Vec<DanmuEntry> = self
|
||||
.cache
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|entry| DanmuEntry {
|
||||
ts: entry.ts - live_start_ts,
|
||||
content: entry.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
// filter out danmus with ts < 0
|
||||
danmus.retain(|entry| entry.ts >= 0);
|
||||
danmus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::database::Database;
|
||||
use crate::progress_manager::Event;
|
||||
use crate::progress_reporter::EventEmitter;
|
||||
use crate::recorder_manager::RecorderEvent;
|
||||
use crate::subtitle_generator::item_to_srt;
|
||||
use crate::{config::Config, database::account::AccountRow};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
@@ -18,8 +19,11 @@ use danmu_stream::danmu_stream::DanmuStream;
|
||||
use danmu_stream::provider::ProviderType;
|
||||
use danmu_stream::DanmuMessageType;
|
||||
use rand::random;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
@@ -55,11 +59,13 @@ pub struct DouyinRecorder {
|
||||
db: Arc<Database>,
|
||||
account: AccountRow,
|
||||
room_id: u64,
|
||||
room_info: Arc<RwLock<Option<response::DouyinRoomInfoResponse>>>,
|
||||
sec_user_id: String,
|
||||
room_info: Arc<RwLock<Option<client::DouyinBasicRoomInfo>>>,
|
||||
stream_url: Arc<RwLock<Option<String>>>,
|
||||
entry_store: Arc<RwLock<Option<EntryStore>>>,
|
||||
danmu_store: Arc<RwLock<Option<DanmuStorage>>>,
|
||||
live_id: Arc<RwLock<String>>,
|
||||
danmu_room_id: Arc<RwLock<String>>,
|
||||
live_status: Arc<RwLock<LiveStatus>>,
|
||||
is_recording: Arc<RwLock<bool>>,
|
||||
running: Arc<RwLock<bool>>,
|
||||
@@ -79,16 +85,17 @@ impl DouyinRecorder {
|
||||
#[cfg(not(feature = "headless"))] app_handle: AppHandle,
|
||||
emitter: EventEmitter,
|
||||
room_id: u64,
|
||||
sec_user_id: &str,
|
||||
config: Arc<RwLock<Config>>,
|
||||
account: &AccountRow,
|
||||
db: &Arc<Database>,
|
||||
enabled: bool,
|
||||
channel: broadcast::Sender<RecorderEvent>,
|
||||
) -> Result<Self, super::errors::RecorderError> {
|
||||
let client = client::DouyinClient::new(account);
|
||||
let room_info = client.get_room_info(room_id).await?;
|
||||
let client = client::DouyinClient::new(&config.read().await.user_agent, account);
|
||||
let room_info = client.get_room_info(room_id, sec_user_id).await?;
|
||||
let mut live_status = LiveStatus::Offline;
|
||||
if room_info.data.room_status == 0 {
|
||||
if room_info.status == 0 {
|
||||
live_status = LiveStatus::Live;
|
||||
}
|
||||
|
||||
@@ -99,7 +106,9 @@ impl DouyinRecorder {
|
||||
db: db.clone(),
|
||||
account: account.clone(),
|
||||
room_id,
|
||||
sec_user_id: sec_user_id.to_string(),
|
||||
live_id: Arc::new(RwLock::new(String::new())),
|
||||
danmu_room_id: Arc::new(RwLock::new(String::new())),
|
||||
entry_store: Arc::new(RwLock::new(None)),
|
||||
danmu_store: Arc::new(RwLock::new(None)),
|
||||
client,
|
||||
@@ -128,9 +137,13 @@ impl DouyinRecorder {
|
||||
}
|
||||
|
||||
async fn check_status(&self) -> bool {
|
||||
match self.client.get_room_info(self.room_id).await {
|
||||
match self
|
||||
.client
|
||||
.get_room_info(self.room_id, &self.sec_user_id)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
let live_status = info.data.room_status == 0; // room_status == 0 表示正在直播
|
||||
let live_status = info.status == 0; // room_status == 0 表示正在直播
|
||||
|
||||
*self.room_info.write().await = Some(info.clone());
|
||||
|
||||
@@ -151,7 +164,7 @@ impl DouyinRecorder {
|
||||
.title("BiliShadowReplay - 直播开始")
|
||||
.body(format!(
|
||||
"{} 开启了直播:{}",
|
||||
info.data.user.nickname, info.data.data[0].title
|
||||
info.user_name, info.room_title
|
||||
))
|
||||
.show()
|
||||
.unwrap();
|
||||
@@ -163,7 +176,7 @@ impl DouyinRecorder {
|
||||
.title("BiliShadowReplay - 直播结束")
|
||||
.body(format!(
|
||||
"{} 关闭了直播:{}",
|
||||
info.data.user.nickname, info.data.data[0].title
|
||||
info.user_name, info.room_title
|
||||
))
|
||||
.show()
|
||||
.unwrap();
|
||||
@@ -196,63 +209,18 @@ impl DouyinRecorder {
|
||||
}
|
||||
|
||||
// Get stream URL when live starts
|
||||
if !info.data.data[0]
|
||||
.stream_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hls_pull_url
|
||||
.is_empty()
|
||||
{
|
||||
*self.live_id.write().await = info.data.data[0].id_str.clone();
|
||||
// create a new record
|
||||
let cover_url = info.data.data[0]
|
||||
.cover
|
||||
.as_ref()
|
||||
.map(|cover| cover.url_list[0].clone());
|
||||
let cover = if let Some(url) = cover_url {
|
||||
Some(self.client.get_cover_base64(&url).await.unwrap())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Err(e) = self
|
||||
.db
|
||||
.add_record(
|
||||
PlatformType::Douyin,
|
||||
self.live_id.read().await.as_str(),
|
||||
self.room_id,
|
||||
&info.data.data[0].title,
|
||||
cover,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to add record: {}", e);
|
||||
if !info.hls_url.is_empty() {
|
||||
// Only set stream URL, don't create record yet
|
||||
// Record will be created when first ts download succeeds
|
||||
let new_stream_url = self.get_best_stream_url(&info).await;
|
||||
if new_stream_url.is_none() {
|
||||
log::error!("No stream url found in room_info: {:#?}", info);
|
||||
return false;
|
||||
}
|
||||
|
||||
// setup entry store
|
||||
let work_dir = self.get_work_dir(self.live_id.read().await.as_str()).await;
|
||||
let entry_store = EntryStore::new(&work_dir).await;
|
||||
*self.entry_store.write().await = Some(entry_store);
|
||||
|
||||
// setup danmu store
|
||||
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
|
||||
let danmu_store = DanmuStorage::new(&danmu_file_path).await;
|
||||
*self.danmu_store.write().await = danmu_store;
|
||||
|
||||
// start danmu task
|
||||
if let Some(danmu_task) = self.danmu_task.lock().await.as_mut() {
|
||||
danmu_task.abort();
|
||||
}
|
||||
if let Some(danmu_stream_task) = self.danmu_stream_task.lock().await.as_mut() {
|
||||
danmu_stream_task.abort();
|
||||
}
|
||||
let live_id = self.live_id.read().await.clone();
|
||||
let self_clone = self.clone();
|
||||
*self.danmu_task.lock().await = Some(tokio::spawn(async move {
|
||||
log::info!("Start fetching danmu for live {}", live_id);
|
||||
let _ = self_clone.danmu().await;
|
||||
}));
|
||||
log::info!("New douyin stream URL: {}", new_stream_url.clone().unwrap());
|
||||
*self.stream_url.write().await = Some(new_stream_url.unwrap());
|
||||
*self.danmu_room_id.write().await = info.room_id_str.clone();
|
||||
}
|
||||
|
||||
true
|
||||
@@ -266,14 +234,14 @@ impl DouyinRecorder {
|
||||
|
||||
async fn danmu(&self) -> Result<(), super::errors::RecorderError> {
|
||||
let cookies = self.account.cookies.clone();
|
||||
let live_id = self
|
||||
.live_id
|
||||
let danmu_room_id = self
|
||||
.danmu_room_id
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.parse::<u64>()
|
||||
.unwrap_or(0);
|
||||
let danmu_stream = DanmuStream::new(ProviderType::Douyin, &cookies, live_id).await;
|
||||
let danmu_stream = DanmuStream::new(ProviderType::Douyin, &cookies, danmu_room_id).await;
|
||||
if danmu_stream.is_err() {
|
||||
let err = danmu_stream.err().unwrap();
|
||||
log::error!("Failed to create danmu stream: {}", err);
|
||||
@@ -290,13 +258,14 @@ impl DouyinRecorder {
|
||||
if let Ok(Some(msg)) = danmu_stream.recv().await {
|
||||
match msg {
|
||||
DanmuMessageType::DanmuMessage(danmu) => {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
self.emitter.emit(&Event::DanmuReceived {
|
||||
room: self.room_id,
|
||||
ts: danmu.timestamp,
|
||||
ts,
|
||||
content: danmu.message.clone(),
|
||||
});
|
||||
if let Some(storage) = self.danmu_store.read().await.as_ref() {
|
||||
storage.add_line(danmu.timestamp, &danmu.message).await;
|
||||
storage.add_line(ts, &danmu.message).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,6 +283,7 @@ impl DouyinRecorder {
|
||||
async fn reset(&self) {
|
||||
*self.entry_store.write().await = None;
|
||||
*self.live_id.write().await = String::new();
|
||||
*self.danmu_room_id.write().await = String::new();
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
*self.stream_url.write().await = None;
|
||||
}
|
||||
@@ -327,18 +297,8 @@ impl DouyinRecorder {
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_best_stream_url(
|
||||
&self,
|
||||
room_info: &response::DouyinRoomInfoResponse,
|
||||
) -> Option<String> {
|
||||
let stream_data = room_info.data.data[0]
|
||||
.stream_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_core_sdk_data
|
||||
.pull_data
|
||||
.stream_data
|
||||
.clone();
|
||||
async fn get_best_stream_url(&self, room_info: &client::DouyinBasicRoomInfo) -> Option<String> {
|
||||
let stream_data = room_info.stream_data.clone();
|
||||
// parse stream_data into stream_info
|
||||
let stream_info = serde_json::from_str::<stream_info::StreamInfo>(&stream_data);
|
||||
if let Ok(stream_info) = stream_info {
|
||||
@@ -356,6 +316,25 @@ impl DouyinRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_stream_url(&self, stream_url: &str) -> (String, String) {
|
||||
// Parse stream URL to extract base URL and query parameters
|
||||
// Example: http://7167739a741646b4651b6949b2f3eb8e.livehwc3.cn/pull-hls-l26.douyincdn.com/third/stream-693342996808860134_or4.m3u8?sub_m3u8=true&user_session_id=16090eb45ab8a2f042f7c46563936187&major_anchor_level=common&edge_slice=true&expire=67d944ec&sign=47b95cc6e8de20d82f3d404412fa8406
|
||||
|
||||
let base_url = stream_url
|
||||
.rfind('/')
|
||||
.map(|i| &stream_url[..=i])
|
||||
.unwrap_or(stream_url)
|
||||
.to_string();
|
||||
|
||||
let query_params = stream_url
|
||||
.find('?')
|
||||
.map(|i| &stream_url[i..])
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
(base_url, query_params)
|
||||
}
|
||||
|
||||
async fn update_entries(&self) -> Result<u128, RecorderError> {
|
||||
let task_begin_time = std::time::Instant::now();
|
||||
|
||||
@@ -367,93 +346,256 @@ impl DouyinRecorder {
|
||||
}
|
||||
|
||||
if self.stream_url.read().await.is_none() {
|
||||
let new_stream_url = self.get_best_stream_url(room_info.as_ref().unwrap()).await;
|
||||
if new_stream_url.is_none() {
|
||||
return Err(RecorderError::NoStreamAvailable);
|
||||
}
|
||||
log::info!("New douyin stream URL: {}", new_stream_url.clone().unwrap());
|
||||
*self.stream_url.write().await = Some(new_stream_url.unwrap());
|
||||
return Err(RecorderError::NoStreamAvailable);
|
||||
}
|
||||
let stream_url = self.stream_url.read().await.as_ref().unwrap().clone();
|
||||
|
||||
let mut stream_url = self.stream_url.read().await.as_ref().unwrap().clone();
|
||||
|
||||
// Get m3u8 playlist
|
||||
let (playlist, updated_stream_url) = self.client.get_m3u8_content(&stream_url).await?;
|
||||
|
||||
*self.stream_url.write().await = Some(updated_stream_url);
|
||||
*self.stream_url.write().await = Some(updated_stream_url.clone());
|
||||
stream_url = updated_stream_url;
|
||||
|
||||
let mut new_segment_fetched = false;
|
||||
let work_dir = self.get_work_dir(self.live_id.read().await.as_str()).await;
|
||||
let mut is_first_segment = self.entry_store.read().await.is_none();
|
||||
let work_dir;
|
||||
|
||||
// Create work directory if not exists
|
||||
tokio::fs::create_dir_all(&work_dir).await?;
|
||||
// If this is the first segment, prepare but don't create directories yet
|
||||
if is_first_segment {
|
||||
// Generate live_id for potential use
|
||||
let live_id = Utc::now().timestamp_millis().to_string();
|
||||
*self.live_id.write().await = live_id.clone();
|
||||
work_dir = self.get_work_dir(&live_id).await;
|
||||
} else {
|
||||
work_dir = self.get_work_dir(self.live_id.read().await.as_str()).await;
|
||||
}
|
||||
|
||||
let last_sequence = self
|
||||
.entry_store
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.last_sequence();
|
||||
let last_sequence = if is_first_segment {
|
||||
0
|
||||
} else {
|
||||
self.entry_store
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.last_sequence
|
||||
};
|
||||
|
||||
for (i, segment) in playlist.segments.iter().enumerate() {
|
||||
let sequence = playlist.media_sequence + i as u64;
|
||||
for segment in playlist.segments.iter() {
|
||||
let formated_ts_name = segment.uri.clone();
|
||||
let sequence = extract_sequence_from(&formated_ts_name);
|
||||
if sequence.is_none() {
|
||||
log::error!(
|
||||
"No timestamp extracted from douyin ts name: {}",
|
||||
formated_ts_name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let sequence = sequence.unwrap();
|
||||
if sequence <= last_sequence {
|
||||
continue;
|
||||
}
|
||||
|
||||
new_segment_fetched = true;
|
||||
let mut uri = segment.uri.clone();
|
||||
// if uri contains ?params, remove it
|
||||
if let Some(pos) = uri.find('?') {
|
||||
uri = uri[..pos].to_string();
|
||||
}
|
||||
// example: pull-l3.douyincdn.com_stream-405850027547689439_or4-1752675567719.ts
|
||||
let uri = segment.uri.clone();
|
||||
|
||||
let ts_url = if uri.starts_with("http") {
|
||||
uri.clone()
|
||||
} else {
|
||||
// Get the base URL without the filename and query parameters
|
||||
let base_url = stream_url
|
||||
.rfind('/')
|
||||
.map(|i| &stream_url[..=i])
|
||||
.unwrap_or(&stream_url);
|
||||
// Get the query parameters
|
||||
let query = stream_url.find('?').map(|i| &stream_url[i..]).unwrap_or("");
|
||||
// Combine: base_url + new_filename + query_params
|
||||
format!("{}{}{}", base_url, uri, query)
|
||||
// Parse the stream URL to extract base URL and query parameters
|
||||
let (base_url, query_params) = self.parse_stream_url(&stream_url);
|
||||
|
||||
// Check if the segment URI already has query parameters
|
||||
if uri.contains('?') {
|
||||
// If segment URI has query params, append m3u8 query params with &
|
||||
format!("{}{}&{}", base_url, uri, &query_params[1..]) // Remove leading ? from query_params
|
||||
} else {
|
||||
// If segment URI has no query params, append m3u8 query params with ?
|
||||
format!("{}{}{}", base_url, uri, query_params)
|
||||
}
|
||||
};
|
||||
|
||||
let file_name = format!("{}.ts", sequence);
|
||||
// Download segment with retry mechanism
|
||||
let mut retry_count = 0;
|
||||
let max_retries = 3;
|
||||
let mut download_success = false;
|
||||
let mut work_dir_created = false;
|
||||
|
||||
// Download segment
|
||||
match self
|
||||
.client
|
||||
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
|
||||
.await
|
||||
{
|
||||
Ok(size) => {
|
||||
let ts_entry = TsEntry {
|
||||
url: file_name,
|
||||
sequence,
|
||||
length: segment.duration as f64,
|
||||
size,
|
||||
ts: Utc::now().timestamp_millis(),
|
||||
is_header: false,
|
||||
};
|
||||
while retry_count < max_retries && !download_success {
|
||||
let file_name = format!("{}.ts", sequence);
|
||||
let file_path = format!("{}/{}", work_dir, file_name);
|
||||
|
||||
self.entry_store
|
||||
.write()
|
||||
.await
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.add_entry(ts_entry)
|
||||
.await;
|
||||
// If this is the first segment, create work directory before first download attempt
|
||||
if is_first_segment && !work_dir_created {
|
||||
// Create work directory only when we're about to download
|
||||
if let Err(e) = tokio::fs::create_dir_all(&work_dir).await {
|
||||
log::error!("Failed to create work directory: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
work_dir_created = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to download segment: {}", e);
|
||||
*self.stream_url.write().await = None;
|
||||
return Err(e.into());
|
||||
|
||||
match self.client.download_ts(&ts_url, &file_path).await {
|
||||
Ok(size) => {
|
||||
if size == 0 {
|
||||
log::error!("Download segment failed (empty response): {}", ts_url);
|
||||
retry_count += 1;
|
||||
if retry_count < max_retries {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// If this is the first successful download, create record and initialize stores
|
||||
if is_first_segment {
|
||||
// Create database record
|
||||
let room_info = room_info.as_ref().unwrap();
|
||||
let cover_url = room_info.cover.clone();
|
||||
let cover = if let Some(url) = cover_url {
|
||||
Some(self.client.get_cover_base64(&url).await.unwrap_or_default())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Err(e) = self
|
||||
.db
|
||||
.add_record(
|
||||
PlatformType::Douyin,
|
||||
self.live_id.read().await.as_str(),
|
||||
self.room_id,
|
||||
&room_info.room_title,
|
||||
cover,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to add record: {}", e);
|
||||
}
|
||||
|
||||
// Setup entry store
|
||||
let entry_store = EntryStore::new(&work_dir).await;
|
||||
*self.entry_store.write().await = Some(entry_store);
|
||||
|
||||
// Setup danmu store
|
||||
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
|
||||
let danmu_store = DanmuStorage::new(&danmu_file_path).await;
|
||||
*self.danmu_store.write().await = danmu_store;
|
||||
|
||||
// Start danmu task
|
||||
if let Some(danmu_task) = self.danmu_task.lock().await.as_mut() {
|
||||
danmu_task.abort();
|
||||
}
|
||||
if let Some(danmu_stream_task) =
|
||||
self.danmu_stream_task.lock().await.as_mut()
|
||||
{
|
||||
danmu_stream_task.abort();
|
||||
}
|
||||
let live_id = self.live_id.read().await.clone();
|
||||
let self_clone = self.clone();
|
||||
*self.danmu_task.lock().await = Some(tokio::spawn(async move {
|
||||
log::info!("Start fetching danmu for live {}", live_id);
|
||||
let _ = self_clone.danmu().await;
|
||||
}));
|
||||
|
||||
is_first_segment = false;
|
||||
}
|
||||
|
||||
let ts_entry = TsEntry {
|
||||
url: file_name,
|
||||
sequence,
|
||||
length: segment.duration as f64,
|
||||
size,
|
||||
ts: Utc::now().timestamp_millis(),
|
||||
is_header: false,
|
||||
};
|
||||
|
||||
self.entry_store
|
||||
.write()
|
||||
.await
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.add_entry(ts_entry)
|
||||
.await;
|
||||
|
||||
new_segment_fetched = true;
|
||||
download_success = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to download segment (attempt {}/{}): {} - URL: {}",
|
||||
retry_count + 1,
|
||||
max_retries,
|
||||
e,
|
||||
ts_url
|
||||
);
|
||||
retry_count += 1;
|
||||
if retry_count < max_retries {
|
||||
tokio::time::sleep(Duration::from_millis(1000 * retry_count as u64))
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
// If all retries failed, check if it's a 400 error
|
||||
if e.to_string().contains("400") {
|
||||
log::error!(
|
||||
"HTTP 400 error for segment, stream URL may be expired: {}",
|
||||
ts_url
|
||||
);
|
||||
*self.stream_url.write().await = None;
|
||||
|
||||
// Clean up empty directory if first segment failed
|
||||
if is_first_segment && work_dir_created {
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Err(RecorderError::NoStreamAvailable);
|
||||
}
|
||||
|
||||
// Clean up empty directory if first segment failed
|
||||
if is_first_segment && work_dir_created {
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await {
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !download_success {
|
||||
log::error!(
|
||||
"Failed to download segment after {} retries: {}",
|
||||
max_retries,
|
||||
ts_url
|
||||
);
|
||||
|
||||
// Clean up empty directory if first segment failed after all retries
|
||||
if is_first_segment && work_dir_created {
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await {
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if new_segment_fetched {
|
||||
@@ -461,6 +603,14 @@ impl DouyinRecorder {
|
||||
self.update_record().await;
|
||||
}
|
||||
|
||||
// if no new segment fetched for 10 seconds
|
||||
if *self.last_update.read().await + 10 < Utc::now().timestamp() {
|
||||
log::warn!("No new segment fetched for 10 seconds");
|
||||
*self.stream_url.write().await = None;
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
return Err(RecorderError::NoStreamAvailable);
|
||||
}
|
||||
|
||||
Ok(task_begin_time.elapsed().as_millis())
|
||||
}
|
||||
|
||||
@@ -511,6 +661,13 @@ impl DouyinRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_sequence_from(name: &str) -> Option<u64> {
|
||||
use regex::Regex;
|
||||
let re = Regex::new(r"(\d+)\.ts").ok()?;
|
||||
let captures = re.captures(name)?;
|
||||
captures.get(1)?.as_str().parse().ok()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Recorder for DouyinRecorder {
|
||||
async fn run(&self) {
|
||||
@@ -597,6 +754,87 @@ impl Recorder for DouyinRecorder {
|
||||
m3u8_content
|
||||
}
|
||||
|
||||
async fn get_archive_subtitle(
|
||||
&self,
|
||||
live_id: &str,
|
||||
) -> Result<String, super::errors::RecorderError> {
|
||||
let work_dir = self.get_work_dir(live_id).await;
|
||||
let subtitle_file_path = format!("{}/{}", work_dir, "subtitle.srt");
|
||||
let subtitle_file = File::open(subtitle_file_path).await;
|
||||
if subtitle_file.is_err() {
|
||||
return Err(super::errors::RecorderError::SubtitleNotFound {
|
||||
live_id: live_id.to_string(),
|
||||
});
|
||||
}
|
||||
let subtitle_file = subtitle_file.unwrap();
|
||||
let mut subtitle_file = BufReader::new(subtitle_file);
|
||||
let mut subtitle_content = String::new();
|
||||
subtitle_file.read_to_string(&mut subtitle_content).await?;
|
||||
Ok(subtitle_content)
|
||||
}
|
||||
|
||||
async fn generate_archive_subtitle(
|
||||
&self,
|
||||
live_id: &str,
|
||||
) -> Result<String, super::errors::RecorderError> {
|
||||
// generate subtitle file under work_dir
|
||||
let work_dir = self.get_work_dir(live_id).await;
|
||||
let subtitle_file_path = format!("{}/{}", work_dir, "subtitle.srt");
|
||||
let mut subtitle_file = File::create(subtitle_file_path).await?;
|
||||
// first generate a tmp clip file
|
||||
// generate a tmp m3u8 index file
|
||||
let m3u8_index_file_path = format!("{}/{}", work_dir, "tmp.m3u8");
|
||||
let m3u8_content = self.m3u8_content(live_id, 0, 0).await;
|
||||
tokio::fs::write(&m3u8_index_file_path, m3u8_content).await?;
|
||||
// generate a tmp clip file
|
||||
let clip_file_path = format!("{}/{}", work_dir, "tmp.mp4");
|
||||
if let Err(e) = crate::ffmpeg::clip_from_m3u8(
|
||||
None::<&crate::progress_reporter::ProgressReporter>,
|
||||
Path::new(&m3u8_index_file_path),
|
||||
Path::new(&clip_file_path),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(super::errors::RecorderError::SubtitleGenerationFailed {
|
||||
error: e.to_string(),
|
||||
});
|
||||
}
|
||||
// generate subtitle file
|
||||
let config = self.config.read().await;
|
||||
let result = crate::ffmpeg::generate_video_subtitle(
|
||||
None,
|
||||
Path::new(&clip_file_path),
|
||||
"whisper",
|
||||
&config.whisper_model,
|
||||
&config.whisper_prompt,
|
||||
&config.openai_api_key,
|
||||
&config.openai_api_endpoint,
|
||||
&config.whisper_language,
|
||||
)
|
||||
.await;
|
||||
// write subtitle file
|
||||
if let Err(e) = result {
|
||||
return Err(super::errors::RecorderError::SubtitleGenerationFailed {
|
||||
error: e.to_string(),
|
||||
});
|
||||
}
|
||||
let result = result.unwrap();
|
||||
let subtitle_content = result
|
||||
.subtitle_content
|
||||
.iter()
|
||||
.map(item_to_srt)
|
||||
.collect::<Vec<String>>()
|
||||
.join("");
|
||||
subtitle_file.write_all(subtitle_content.as_bytes()).await?;
|
||||
|
||||
// remove tmp file
|
||||
tokio::fs::remove_file(&m3u8_index_file_path).await?;
|
||||
tokio::fs::remove_file(&clip_file_path).await?;
|
||||
Ok(subtitle_content)
|
||||
}
|
||||
|
||||
async fn first_segment_ts(&self, live_id: &str) -> i64 {
|
||||
if *self.live_id.read().await == live_id {
|
||||
let entry_store = self.entry_store.read().await;
|
||||
@@ -615,17 +853,11 @@ impl Recorder for DouyinRecorder {
|
||||
let room_info = self.room_info.read().await;
|
||||
let room_cover_url = room_info
|
||||
.as_ref()
|
||||
.and_then(|info| {
|
||||
info.data
|
||||
.data
|
||||
.first()
|
||||
.and_then(|data| data.cover.as_ref())
|
||||
.map(|cover| cover.url_list[0].clone())
|
||||
})
|
||||
.and_then(|info| info.cover.clone())
|
||||
.unwrap_or_default();
|
||||
let room_title = room_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.data.data.first().map(|data| data.title.clone()))
|
||||
.map(|info| info.room_title.clone())
|
||||
.unwrap_or_default();
|
||||
RecorderInfo {
|
||||
room_id: self.room_id,
|
||||
@@ -637,15 +869,15 @@ impl Recorder for DouyinRecorder {
|
||||
user_info: UserInfo {
|
||||
user_id: room_info
|
||||
.as_ref()
|
||||
.map(|info| info.data.user.sec_uid.clone())
|
||||
.map(|info| info.sec_user_id.clone())
|
||||
.unwrap_or_default(),
|
||||
user_name: room_info
|
||||
.as_ref()
|
||||
.map(|info| info.data.user.nickname.clone())
|
||||
.map(|info| info.user_name.clone())
|
||||
.unwrap_or_default(),
|
||||
user_avatar: room_info
|
||||
.as_ref()
|
||||
.map(|info| info.data.user.avatar_thumb.url_list[0].clone())
|
||||
.map(|info| info.user_avatar.clone())
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
total_length: if let Some(store) = self.entry_store.read().await.as_ref() {
|
||||
@@ -665,7 +897,11 @@ impl Recorder for DouyinRecorder {
|
||||
Ok(if live_id == *self.live_id.read().await {
|
||||
// just return current cache content
|
||||
match self.danmu_store.read().await.as_ref() {
|
||||
Some(storage) => storage.get_entries().await,
|
||||
Some(storage) => {
|
||||
storage
|
||||
.get_entries(self.first_segment_ts(live_id).await)
|
||||
.await
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
@@ -683,7 +919,9 @@ impl Recorder for DouyinRecorder {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let storage = storage.unwrap();
|
||||
storage.get_entries().await
|
||||
storage
|
||||
.get_entries(self.first_segment_ts(live_id).await)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ use reqwest::{Client, Error as ReqwestError};
|
||||
use super::response::DouyinRoomInfoResponse;
|
||||
use std::fmt;
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DouyinClientError {
|
||||
Network(ReqwestError),
|
||||
Network(String),
|
||||
Io(std::io::Error),
|
||||
Playlist(String),
|
||||
}
|
||||
@@ -27,7 +25,7 @@ impl fmt::Display for DouyinClientError {
|
||||
|
||||
impl From<ReqwestError> for DouyinClientError {
|
||||
fn from(err: ReqwestError) -> Self {
|
||||
DouyinClientError::Network(err)
|
||||
DouyinClientError::Network(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,27 +35,42 @@ impl From<std::io::Error> for DouyinClientError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DouyinBasicRoomInfo {
|
||||
pub room_id_str: String,
|
||||
pub room_title: String,
|
||||
pub cover: Option<String>,
|
||||
pub status: i64,
|
||||
pub hls_url: String,
|
||||
pub stream_data: String,
|
||||
// user related
|
||||
pub user_name: String,
|
||||
pub user_avatar: String,
|
||||
pub sec_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DouyinClient {
|
||||
client: Client,
|
||||
cookies: String,
|
||||
account: AccountRow,
|
||||
}
|
||||
|
||||
impl DouyinClient {
|
||||
pub fn new(account: &AccountRow) -> Self {
|
||||
let client = Client::builder().user_agent(USER_AGENT).build().unwrap();
|
||||
pub fn new(user_agent: &str, account: &AccountRow) -> Self {
|
||||
let client = Client::builder().user_agent(user_agent).build().unwrap();
|
||||
Self {
|
||||
client,
|
||||
cookies: account.cookies.clone(),
|
||||
account: account.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_room_info(
|
||||
&self,
|
||||
room_id: u64,
|
||||
) -> Result<DouyinRoomInfoResponse, DouyinClientError> {
|
||||
sec_user_id: &str,
|
||||
) -> Result<DouyinBasicRoomInfo, DouyinClientError> {
|
||||
let url = format!(
|
||||
"https://live.douyin.com/webcast/room/web/enter/?aid=6383&app_name=douyin_web&live_id=1&device_platform=web&language=zh-CN&enter_from=web_live&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Chrome&browser_version=122.0.0.0&web_rid={}",
|
||||
"https://live.douyin.com/webcast/room/web/enter/?aid=6383&app_name=douyin_web&live_id=1&device_platform=web&language=zh-CN&enter_from=web_live&a_bogus=0&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Chrome&browser_version=122.0.0.0&web_rid={}",
|
||||
room_id
|
||||
);
|
||||
|
||||
@@ -65,14 +78,257 @@ impl DouyinClient {
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Referer", "https://live.douyin.com/")
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", self.cookies.clone())
|
||||
.header("Cookie", self.account.cookies.clone())
|
||||
.send()
|
||||
.await?
|
||||
.json::<DouyinRoomInfoResponse>()
|
||||
.await?;
|
||||
|
||||
Ok(resp)
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
|
||||
if text.is_empty() {
|
||||
log::warn!("Empty room info response, trying H5 API");
|
||||
return self.get_room_info_h5(room_id, sec_user_id).await;
|
||||
}
|
||||
|
||||
if status.is_success() {
|
||||
if let Ok(data) = serde_json::from_str::<DouyinRoomInfoResponse>(&text) {
|
||||
let cover = data
|
||||
.data
|
||||
.data
|
||||
.first()
|
||||
.and_then(|data| data.cover.as_ref())
|
||||
.map(|cover| cover.url_list[0].clone());
|
||||
return Ok(DouyinBasicRoomInfo {
|
||||
room_id_str: data.data.data[0].id_str.clone(),
|
||||
sec_user_id: sec_user_id.to_string(),
|
||||
cover,
|
||||
room_title: data.data.data[0].title.clone(),
|
||||
user_name: data.data.user.nickname.clone(),
|
||||
user_avatar: data.data.user.avatar_thumb.url_list[0].clone(),
|
||||
status: data.data.room_status,
|
||||
hls_url: data.data.data[0]
|
||||
.stream_url
|
||||
.as_ref()
|
||||
.map(|stream_url| stream_url.hls_pull_url.clone())
|
||||
.unwrap_or_default(),
|
||||
stream_data: data.data.data[0]
|
||||
.stream_url
|
||||
.as_ref()
|
||||
.map(|s| s.live_core_sdk_data.pull_data.stream_data.clone())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
} else {
|
||||
log::error!("Failed to parse room info response: {}", text);
|
||||
return self.get_room_info_h5(room_id, sec_user_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
log::error!("Failed to get room info: {}", status);
|
||||
return self.get_room_info_h5(room_id, sec_user_id).await;
|
||||
}
|
||||
|
||||
pub async fn get_room_info_h5(
|
||||
&self,
|
||||
room_id: u64,
|
||||
sec_user_id: &str,
|
||||
) -> Result<DouyinBasicRoomInfo, DouyinClientError> {
|
||||
// 参考biliup实现,构建完整的URL参数
|
||||
let room_id_str = room_id.to_string();
|
||||
// https://webcast.amemv.com/webcast/room/reflow/info/?type_id=0&live_id=1&version_code=99.99.99&app_id=1128&room_id=10000&sec_user_id=MS4wLjAB&aid=6383&device_platform=web&browser_language=zh-CN&browser_platform=Win32&browser_name=Mozilla&browser_version=5.0
|
||||
let url_params = [
|
||||
("type_id", "0"),
|
||||
("live_id", "1"),
|
||||
("version_code", "99.99.99"),
|
||||
("app_id", "1128"),
|
||||
("room_id", &room_id_str),
|
||||
("sec_user_id", sec_user_id),
|
||||
("aid", "6383"),
|
||||
("device_platform", "web"),
|
||||
];
|
||||
|
||||
// 构建URL
|
||||
let query_string = url_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
let url = format!(
|
||||
"https://webcast.amemv.com/webcast/room/reflow/info/?{}",
|
||||
query_string
|
||||
);
|
||||
|
||||
log::info!("get_room_info_h5: {}", url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
|
||||
.header("Referer", "https://live.douyin.com/")
|
||||
.header("Cookie", self.account.cookies.clone())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
|
||||
if status.is_success() {
|
||||
// Try to parse as H5 response format
|
||||
if let Ok(h5_data) =
|
||||
serde_json::from_str::<super::response::DouyinH5RoomInfoResponse>(&text)
|
||||
{
|
||||
// Extract RoomBasicInfo from H5 response
|
||||
let room = &h5_data.data.room;
|
||||
let owner = &room.owner;
|
||||
|
||||
let cover = room
|
||||
.cover
|
||||
.as_ref()
|
||||
.and_then(|c| c.url_list.first().cloned());
|
||||
let hls_url = room
|
||||
.stream_url
|
||||
.as_ref()
|
||||
.map(|s| s.hls_pull_url.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
return Ok(DouyinBasicRoomInfo {
|
||||
room_id_str: room.id_str.clone(),
|
||||
room_title: room.title.clone(),
|
||||
cover,
|
||||
status: if room.status == 2 { 0 } else { 1 },
|
||||
hls_url,
|
||||
user_name: owner.nickname.clone(),
|
||||
user_avatar: owner
|
||||
.avatar_thumb
|
||||
.url_list
|
||||
.first()
|
||||
.unwrap_or(&String::new())
|
||||
.clone(),
|
||||
sec_user_id: owner.sec_uid.clone(),
|
||||
stream_data: room
|
||||
.stream_url
|
||||
.as_ref()
|
||||
.map(|s| s.live_core_sdk_data.pull_data.stream_data.clone())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
// If that fails, try to parse as a generic JSON to see what we got
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
log::error!(
|
||||
"Unexpected response structure: {}",
|
||||
serde_json::to_string_pretty(&json_value).unwrap_or_default()
|
||||
);
|
||||
|
||||
// Check if it's an error response
|
||||
if let Some(status_code) = json_value.get("status_code").and_then(|v| v.as_i64()) {
|
||||
if status_code != 0 {
|
||||
let error_msg = json_value
|
||||
.get("status_message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(DouyinClientError::Network(format!(
|
||||
"API returned error status_code: {} - {}",
|
||||
status_code, error_msg
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是"invalid session"错误
|
||||
if let Some(status_message) =
|
||||
json_value.get("status_message").and_then(|v| v.as_str())
|
||||
{
|
||||
if status_message.contains("invalid session") {
|
||||
return Err(DouyinClientError::Network(
|
||||
"Invalid session - please check your cookies. Make sure you have valid sessionid, passport_csrf_token, and other authentication cookies from douyin.com".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(DouyinClientError::Network(format!(
|
||||
"Failed to parse h5 room info response: {}",
|
||||
text
|
||||
)));
|
||||
} else {
|
||||
log::error!("Failed to parse h5 room info response: {}", text);
|
||||
return Err(DouyinClientError::Network(format!(
|
||||
"Failed to parse h5 room info response: {}",
|
||||
text
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
log::error!("Failed to get h5 room info: {}", status);
|
||||
Err(DouyinClientError::Network(format!(
|
||||
"Failed to get h5 room info: {} {}",
|
||||
status, text
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn get_user_info(&self) -> Result<super::response::User, DouyinClientError> {
|
||||
// Use the IM spotlight relation API to get user info
|
||||
let url = "https://www.douyin.com/aweme/v1/web/im/spotlight/relation/";
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("Referer", "https://www.douyin.com/")
|
||||
.header("Cookie", self.account.cookies.clone())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
|
||||
if status.is_success() {
|
||||
if let Ok(data) = serde_json::from_str::<super::response::DouyinRelationResponse>(&text)
|
||||
{
|
||||
if data.status_code == 0 {
|
||||
let owner_sec_uid = &data.owner_sec_uid;
|
||||
|
||||
// Find the user's own info in the followings list by matching sec_uid
|
||||
if let Some(followings) = &data.followings {
|
||||
for following in followings {
|
||||
if following.sec_uid == *owner_sec_uid {
|
||||
let user = super::response::User {
|
||||
id_str: following.uid.clone(),
|
||||
sec_uid: following.sec_uid.clone(),
|
||||
nickname: following.nickname.clone(),
|
||||
avatar_thumb: following.avatar_thumb.clone(),
|
||||
follow_info: super::response::FollowInfo::default(),
|
||||
foreign_user: 0,
|
||||
open_id_str: "".to_string(),
|
||||
};
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in followings, create a minimal user info from owner_sec_uid
|
||||
let user = super::response::User {
|
||||
id_str: "".to_string(), // We don't have the numeric UID
|
||||
sec_uid: owner_sec_uid.clone(),
|
||||
nickname: "抖音用户".to_string(), // Default nickname
|
||||
avatar_thumb: super::response::AvatarThumb { url_list: vec![] },
|
||||
follow_info: super::response::FollowInfo::default(),
|
||||
foreign_user: 0,
|
||||
open_id_str: "".to_string(),
|
||||
};
|
||||
return Ok(user);
|
||||
}
|
||||
} else {
|
||||
log::error!("Failed to parse user info response: {}", text);
|
||||
return Err(DouyinClientError::Network(format!(
|
||||
"Failed to parse user info response: {}",
|
||||
text
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
log::error!("Failed to get user info: {}", status);
|
||||
|
||||
Err(DouyinClientError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"Failed to get user info from Douyin relation API",
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn get_cover_base64(&self, url: &str) -> Result<String, DouyinClientError> {
|
||||
@@ -96,6 +352,7 @@ impl DouyinClient {
|
||||
// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000
|
||||
// http://7167739a741646b4651b6949b2f3eb8e.livehwc3.cn/pull-hls-l26.douyincdn.com/third/stream-693342996808860134_or4.m3u8?sub_m3u8=true&user_session_id=16090eb45ab8a2f042f7c46563936187&major_anchor_level=common&edge_slice=true&expire=67d944ec&sign=47b95cc6e8de20d82f3d404412fa8406
|
||||
if content.contains("BANDWIDTH") {
|
||||
log::info!("Master manifest with playlist URL: {}", url);
|
||||
let new_url = content.lines().last().unwrap();
|
||||
return Box::pin(self.get_m3u8_content(new_url)).await;
|
||||
}
|
||||
@@ -113,9 +370,9 @@ impl DouyinClient {
|
||||
let response = self.client.get(url).send().await?;
|
||||
|
||||
if response.status() != reqwest::StatusCode::OK {
|
||||
return Err(DouyinClientError::Network(
|
||||
response.error_for_status().unwrap_err(),
|
||||
));
|
||||
let error = response.error_for_status().unwrap_err();
|
||||
log::error!("HTTP error: {} for URL: {}", error, url);
|
||||
return Err(DouyinClientError::Network(error.to_string()));
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::create(path).await?;
|
||||
|
||||
@@ -182,8 +182,7 @@ pub struct Extra {
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullDatas {
|
||||
}
|
||||
pub struct PullDatas {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -436,8 +435,7 @@ pub struct Stats {
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkerMap {
|
||||
}
|
||||
pub struct LinkerMap {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -478,13 +476,11 @@ pub struct LinkerDetail {
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkerMapStr {
|
||||
}
|
||||
pub struct LinkerMapStr {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaymodeDetail {
|
||||
}
|
||||
pub struct PlaymodeDetail {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -589,4 +585,208 @@ pub struct User {
|
||||
pub foreign_user: i64,
|
||||
#[serde(rename = "open_id_str")]
|
||||
pub open_id_str: String,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DouyinRelationResponse {
|
||||
pub extra: Option<Extra2>,
|
||||
pub followings: Option<Vec<Following>>,
|
||||
#[serde(rename = "owner_sec_uid")]
|
||||
pub owner_sec_uid: String,
|
||||
#[serde(rename = "status_code")]
|
||||
pub status_code: i64,
|
||||
#[serde(rename = "log_pb")]
|
||||
pub log_pb: Option<LogPb>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Extra2 {
|
||||
#[serde(rename = "fatal_item_ids")]
|
||||
pub fatal_item_ids: Vec<String>,
|
||||
pub logid: String,
|
||||
pub now: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogPb {
|
||||
#[serde(rename = "impr_id")]
|
||||
pub impr_id: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Following {
|
||||
#[serde(rename = "account_cert_info")]
|
||||
pub account_cert_info: String,
|
||||
#[serde(rename = "avatar_signature")]
|
||||
pub avatar_signature: String,
|
||||
#[serde(rename = "avatar_small")]
|
||||
pub avatar_small: AvatarSmall,
|
||||
#[serde(rename = "avatar_thumb")]
|
||||
pub avatar_thumb: AvatarThumb,
|
||||
#[serde(rename = "birthday_hide_level")]
|
||||
pub birthday_hide_level: i64,
|
||||
#[serde(rename = "commerce_user_level")]
|
||||
pub commerce_user_level: i64,
|
||||
#[serde(rename = "custom_verify")]
|
||||
pub custom_verify: String,
|
||||
#[serde(rename = "enterprise_verify_reason")]
|
||||
pub enterprise_verify_reason: String,
|
||||
#[serde(rename = "follow_status")]
|
||||
pub follow_status: i64,
|
||||
#[serde(rename = "follower_status")]
|
||||
pub follower_status: i64,
|
||||
#[serde(rename = "has_e_account_role")]
|
||||
pub has_e_account_role: bool,
|
||||
#[serde(rename = "im_activeness")]
|
||||
pub im_activeness: i64,
|
||||
#[serde(rename = "im_role_ids")]
|
||||
pub im_role_ids: Vec<serde_json::Value>,
|
||||
#[serde(rename = "is_im_oversea_user")]
|
||||
pub is_im_oversea_user: i64,
|
||||
pub nickname: String,
|
||||
#[serde(rename = "sec_uid")]
|
||||
pub sec_uid: String,
|
||||
#[serde(rename = "short_id")]
|
||||
pub short_id: String,
|
||||
pub signature: String,
|
||||
#[serde(rename = "social_relation_sub_type")]
|
||||
pub social_relation_sub_type: i64,
|
||||
#[serde(rename = "social_relation_type")]
|
||||
pub social_relation_type: i64,
|
||||
pub uid: String,
|
||||
#[serde(rename = "unique_id")]
|
||||
pub unique_id: String,
|
||||
#[serde(rename = "verification_type")]
|
||||
pub verification_type: i64,
|
||||
#[serde(rename = "webcast_sp_info")]
|
||||
pub webcast_sp_info: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AvatarSmall {
|
||||
pub uri: String,
|
||||
#[serde(rename = "url_list")]
|
||||
pub url_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DouyinH5RoomInfoResponse {
|
||||
pub data: H5Data,
|
||||
pub extra: H5Extra,
|
||||
#[serde(rename = "status_code")]
|
||||
pub status_code: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5Data {
|
||||
pub room: H5Room,
|
||||
pub user: H5User,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5Room {
|
||||
pub id: u64,
|
||||
#[serde(rename = "id_str")]
|
||||
pub id_str: String,
|
||||
pub status: i64,
|
||||
pub title: String,
|
||||
pub cover: Option<H5Cover>,
|
||||
#[serde(rename = "stream_url")]
|
||||
pub stream_url: Option<H5StreamUrl>,
|
||||
pub owner: H5Owner,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5Cover {
|
||||
#[serde(rename = "url_list")]
|
||||
pub url_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5StreamUrl {
|
||||
pub provider: i64,
|
||||
pub id: u64,
|
||||
#[serde(rename = "id_str")]
|
||||
pub id_str: String,
|
||||
#[serde(rename = "default_resolution")]
|
||||
pub default_resolution: String,
|
||||
#[serde(rename = "rtmp_pull_url")]
|
||||
pub rtmp_pull_url: String,
|
||||
#[serde(rename = "flv_pull_url")]
|
||||
pub flv_pull_url: H5FlvPullUrl,
|
||||
#[serde(rename = "hls_pull_url")]
|
||||
pub hls_pull_url: String,
|
||||
#[serde(rename = "hls_pull_url_map")]
|
||||
pub hls_pull_url_map: H5HlsPullUrlMap,
|
||||
#[serde(rename = "live_core_sdk_data")]
|
||||
pub live_core_sdk_data: LiveCoreSdkData,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5FlvPullUrl {
|
||||
#[serde(rename = "FULL_HD1")]
|
||||
pub full_hd1: Option<String>,
|
||||
#[serde(rename = "HD1")]
|
||||
pub hd1: Option<String>,
|
||||
#[serde(rename = "SD1")]
|
||||
pub sd1: Option<String>,
|
||||
#[serde(rename = "SD2")]
|
||||
pub sd2: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5HlsPullUrlMap {
|
||||
#[serde(rename = "FULL_HD1")]
|
||||
pub full_hd1: Option<String>,
|
||||
#[serde(rename = "HD1")]
|
||||
pub hd1: Option<String>,
|
||||
#[serde(rename = "SD1")]
|
||||
pub sd1: Option<String>,
|
||||
#[serde(rename = "SD2")]
|
||||
pub sd2: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5Owner {
|
||||
pub nickname: String,
|
||||
#[serde(rename = "avatar_thumb")]
|
||||
pub avatar_thumb: H5AvatarThumb,
|
||||
#[serde(rename = "sec_uid")]
|
||||
pub sec_uid: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5AvatarThumb {
|
||||
#[serde(rename = "url_list")]
|
||||
pub url_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5User {
|
||||
pub nickname: String,
|
||||
#[serde(rename = "avatar_thumb")]
|
||||
pub avatar_thumb: Option<H5AvatarThumb>,
|
||||
#[serde(rename = "sec_uid")]
|
||||
pub sec_uid: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct H5Extra {
|
||||
pub now: i64,
|
||||
}
|
||||
|
||||
@@ -51,13 +51,22 @@ impl TsEntry {
|
||||
pub fn ts_seconds(&self) -> i64 {
|
||||
// For some legacy problem, douyin entry's ts is s, bilibili entry's ts is ms.
|
||||
// This should be fixed after 2.5.6, but we need to support entry.log generated by previous version.
|
||||
if self.ts > 1619884800000 {
|
||||
if self.ts > 10000000000 {
|
||||
self.ts / 1000
|
||||
} else {
|
||||
self.ts
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ts_mili(&self) -> i64 {
|
||||
// if already in ms, return as is
|
||||
if self.ts > 10000000000 {
|
||||
self.ts
|
||||
} else {
|
||||
self.ts * 1000
|
||||
}
|
||||
}
|
||||
|
||||
pub fn date_time(&self) -> String {
|
||||
let date_str = Utc
|
||||
.timestamp_opt(self.ts_seconds(), 0)
|
||||
@@ -74,7 +83,7 @@ impl TsEntry {
|
||||
|
||||
let mut content = String::new();
|
||||
|
||||
content += &format!("#EXTINF:{:.2},\n", self.length);
|
||||
content += &format!("#EXTINF:{:.4},\n", self.length);
|
||||
content += &format!("{}\n", self.url);
|
||||
|
||||
content
|
||||
@@ -100,9 +109,7 @@ pub struct EntryStore {
|
||||
entries: Vec<TsEntry>,
|
||||
total_duration: f64,
|
||||
total_size: u64,
|
||||
last_sequence: u64,
|
||||
|
||||
pub continue_sequence: u64,
|
||||
pub last_sequence: u64,
|
||||
}
|
||||
|
||||
impl EntryStore {
|
||||
@@ -125,7 +132,6 @@ impl EntryStore {
|
||||
total_duration: 0.0,
|
||||
total_size: 0,
|
||||
last_sequence: 0,
|
||||
continue_sequence: 0,
|
||||
};
|
||||
|
||||
entry_store.load(work_dir).await;
|
||||
@@ -150,9 +156,7 @@ impl EntryStore {
|
||||
|
||||
let entry = entry.unwrap();
|
||||
|
||||
if entry.sequence > self.last_sequence {
|
||||
self.last_sequence = entry.sequence;
|
||||
}
|
||||
self.last_sequence = std::cmp::max(self.last_sequence, entry.sequence);
|
||||
|
||||
if entry.is_header {
|
||||
self.header = Some(entry.clone());
|
||||
@@ -163,8 +167,6 @@ impl EntryStore {
|
||||
self.total_duration += entry.length;
|
||||
self.total_size += entry.size;
|
||||
}
|
||||
|
||||
self.continue_sequence = self.last_sequence + 100;
|
||||
}
|
||||
|
||||
pub async fn add_entry(&mut self, entry: TsEntry) {
|
||||
@@ -180,9 +182,7 @@ impl EntryStore {
|
||||
|
||||
self.log_file.flush().await.unwrap();
|
||||
|
||||
if self.last_sequence < entry.sequence {
|
||||
self.last_sequence = entry.sequence;
|
||||
}
|
||||
self.last_sequence = std::cmp::max(self.last_sequence, entry.sequence);
|
||||
|
||||
self.total_duration += entry.length;
|
||||
self.total_size += entry.size;
|
||||
@@ -200,16 +200,14 @@ impl EntryStore {
|
||||
self.total_size
|
||||
}
|
||||
|
||||
pub fn last_sequence(&self) -> u64 {
|
||||
self.last_sequence
|
||||
}
|
||||
|
||||
pub fn last_ts(&self) -> Option<i64> {
|
||||
self.entries.last().map(|entry| entry.ts)
|
||||
}
|
||||
|
||||
/// Get first timestamp in milliseconds
|
||||
pub fn first_ts(&self) -> Option<i64> {
|
||||
self.entries.first().map(|e| e.ts)
|
||||
self.entries.first().map(|x| x.ts_mili())
|
||||
}
|
||||
|
||||
/// Get last timestamp in milliseconds
|
||||
pub fn last_ts(&self) -> Option<i64> {
|
||||
self.entries.last().map(|x| x.ts_mili())
|
||||
}
|
||||
|
||||
/// Generate a hls manifest for selected range.
|
||||
@@ -257,6 +255,7 @@ impl EntryStore {
|
||||
|
||||
if entries_in_range.is_empty() {
|
||||
m3u8_content += end_content;
|
||||
log::warn!("No entries in range, return empty manifest");
|
||||
return m3u8_content;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,8 @@ custom_error! {pub RecorderError
|
||||
DouyinClientError {err: DouyinClientError} = "DouyinClient error: {err}",
|
||||
IoError {err: std::io::Error} = "IO error: {err}",
|
||||
DanmuStreamError {err: danmu_stream::DanmuStreamError} = "Danmu stream error: {err}",
|
||||
SubtitleNotFound {live_id: String} = "Subtitle not found: {live_id}",
|
||||
SubtitleGenerationFailed {error: String} = "Subtitle generation failed: {error}",
|
||||
FfmpegError {err: String} = "FFmpeg error: {err}",
|
||||
ResolutionChanged {err: String} = "Resolution changed: {err}",
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
use actix_web::Response;
|
||||
|
||||
fn handle_hls_request(ts_path: Option<&str>) -> Response {
|
||||
if let Some(ts_path) = ts_path {
|
||||
if let Ok(content) = std::fs::read(ts_path) {
|
||||
return Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", "video/mp2t")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(content)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Response::builder()
|
||||
.status(404)
|
||||
.header("Content-Type", "text/plain")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(b"Not Found".to_vec())
|
||||
.unwrap()
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use crate::danmu2ass;
|
||||
use crate::database::video::VideoRow;
|
||||
use crate::database::{account::AccountRow, record::RecordRow};
|
||||
use crate::database::{Database, DatabaseError};
|
||||
use crate::ffmpeg::{clip_from_m3u8, encode_video_danmu};
|
||||
use crate::ffmpeg::{clip_from_m3u8, encode_video_danmu, Range};
|
||||
use crate::progress_reporter::{EventEmitter, ProgressReporter};
|
||||
use crate::recorder::bilibili::{BiliRecorder, BiliRecorderOptions};
|
||||
use crate::recorder::danmu::DanmuEntry;
|
||||
@@ -39,15 +39,12 @@ pub struct ClipRangeParams {
|
||||
pub platform: String,
|
||||
pub room_id: u64,
|
||||
pub live_id: String,
|
||||
/// Clip range start in seconds
|
||||
pub x: i64,
|
||||
/// Clip range end in seconds
|
||||
pub y: i64,
|
||||
/// Timestamp of first stream segment in seconds
|
||||
pub offset: i64,
|
||||
pub range: Option<Range>,
|
||||
/// Encode danmu after clip
|
||||
pub danmu: bool,
|
||||
pub local_offset: i64,
|
||||
/// Fix encoding after clip
|
||||
pub fix_encoding: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -205,11 +202,10 @@ impl RecorderManager {
|
||||
platform: live_record.platform.clone(),
|
||||
room_id,
|
||||
live_id: live_id.to_string(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
offset: recorder.first_segment_ts(live_id).await,
|
||||
range: None,
|
||||
danmu: encode_danmu,
|
||||
local_offset: 0,
|
||||
fix_encoding: false,
|
||||
};
|
||||
|
||||
let clip_filename = self.config.read().await.generate_clip_name(&clip_config);
|
||||
@@ -292,7 +288,8 @@ impl RecorderManager {
|
||||
let platform = PlatformType::from_str(&recorder.platform).unwrap();
|
||||
let room_id = recorder.room_id;
|
||||
let auto_start = recorder.auto_start;
|
||||
recorder_map.insert((platform, room_id), auto_start);
|
||||
let extra = recorder.extra;
|
||||
recorder_map.insert((platform, room_id), (auto_start, extra));
|
||||
}
|
||||
let mut recorders_to_add = Vec::new();
|
||||
for (platform, room_id) in recorder_map.keys() {
|
||||
@@ -307,7 +304,7 @@ impl RecorderManager {
|
||||
if self.is_migrating.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let auto_start = recorder_map.get(&(platform, room_id)).unwrap();
|
||||
let (auto_start, extra) = recorder_map.get(&(platform, room_id)).unwrap();
|
||||
let account = self
|
||||
.db
|
||||
.get_account_by_platform(platform.clone().as_str())
|
||||
@@ -319,7 +316,7 @@ impl RecorderManager {
|
||||
let account = account.unwrap();
|
||||
|
||||
if let Err(e) = self
|
||||
.add_recorder(&account, platform, room_id, *auto_start)
|
||||
.add_recorder(&account, platform, room_id, extra, *auto_start)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to add recorder: {}", e);
|
||||
@@ -334,6 +331,7 @@ impl RecorderManager {
|
||||
account: &AccountRow,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
extra: &str,
|
||||
auto_start: bool,
|
||||
) -> Result<(), RecorderManagerError> {
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
@@ -363,6 +361,7 @@ impl RecorderManager {
|
||||
self.app_handle.clone(),
|
||||
self.emitter.clone(),
|
||||
room_id,
|
||||
extra,
|
||||
self.config.clone(),
|
||||
account,
|
||||
&self.db,
|
||||
@@ -475,14 +474,21 @@ impl RecorderManager {
|
||||
params: &ClipRangeParams,
|
||||
) -> Result<PathBuf, RecorderManagerError> {
|
||||
let range_m3u8 = format!(
|
||||
"{}/{}/{}/playlist.m3u8?start={}&end={}",
|
||||
params.platform, params.room_id, params.live_id, params.x, params.y
|
||||
"{}/{}/{}/playlist.m3u8",
|
||||
params.platform, params.room_id, params.live_id
|
||||
);
|
||||
|
||||
let manifest_content = self.handle_hls_request(&range_m3u8).await?;
|
||||
let manifest_content = String::from_utf8(manifest_content)
|
||||
let mut manifest_content = String::from_utf8(manifest_content)
|
||||
.map_err(|e| RecorderManagerError::ClipError { err: e.to_string() })?;
|
||||
|
||||
// if manifest is for stream, replace EXT-X-PLAYLIST-TYPE:EVENT to EXT-X-PLAYLIST-TYPE:VOD, and add #EXT-X-ENDLIST
|
||||
if manifest_content.contains("#EXT-X-PLAYLIST-TYPE:EVENT") {
|
||||
manifest_content =
|
||||
manifest_content.replace("#EXT-X-PLAYLIST-TYPE:EVENT", "#EXT-X-PLAYLIST-TYPE:VOD");
|
||||
manifest_content += "\n#EXT-X-ENDLIST\n";
|
||||
}
|
||||
|
||||
let cache_path = self.config.read().await.cache.clone();
|
||||
let cache_path = Path::new(&cache_path);
|
||||
let random_filename = format!("manifest_{}.m3u8", uuid::Uuid::new_v4());
|
||||
@@ -497,7 +503,15 @@ impl RecorderManager {
|
||||
.await
|
||||
.map_err(|e| RecorderManagerError::ClipError { err: e.to_string() })?;
|
||||
|
||||
if let Err(e) = clip_from_m3u8(reporter, &tmp_manifest_file_path, &clip_file).await {
|
||||
if let Err(e) = clip_from_m3u8(
|
||||
reporter,
|
||||
&tmp_manifest_file_path,
|
||||
&clip_file,
|
||||
params.range.as_ref(),
|
||||
params.fix_encoding,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to generate clip file: {}", e);
|
||||
return Err(RecorderManagerError::ClipError { err: e.to_string() });
|
||||
}
|
||||
@@ -505,6 +519,14 @@ impl RecorderManager {
|
||||
// remove temp file
|
||||
let _ = tokio::fs::remove_file(tmp_manifest_file_path).await;
|
||||
|
||||
// check clip_file exists
|
||||
if !clip_file.exists() {
|
||||
log::error!("Clip file not found: {}", clip_file.display());
|
||||
return Err(RecorderManagerError::ClipError {
|
||||
err: "Clip file not found".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if !params.danmu {
|
||||
return Ok(clip_file);
|
||||
}
|
||||
@@ -516,20 +538,24 @@ impl RecorderManager {
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Filter danmus in range [{}, {}] with global offset {} and local offset {}",
|
||||
params.x,
|
||||
params.y,
|
||||
params.offset,
|
||||
"Filter danmus in range {} with local offset {}",
|
||||
params
|
||||
.range
|
||||
.as_ref()
|
||||
.map_or("None".to_string(), |r| r.to_string()),
|
||||
params.local_offset
|
||||
);
|
||||
let mut danmus = danmus.unwrap();
|
||||
log::debug!("First danmu entry: {:?}", danmus.first());
|
||||
// update entry ts to offset
|
||||
for d in &mut danmus {
|
||||
d.ts -= (params.x + params.offset + params.local_offset) * 1000;
|
||||
}
|
||||
if params.x != 0 || params.y != 0 {
|
||||
danmus.retain(|x| x.ts >= 0 && x.ts <= (params.y - params.x) * 1000);
|
||||
|
||||
if let Some(range) = ¶ms.range {
|
||||
// update entry ts to offset and filter danmus in range
|
||||
for d in &mut danmus {
|
||||
d.ts -= (range.start as i64 + params.local_offset) * 1000;
|
||||
}
|
||||
if range.duration() > 0.0 {
|
||||
danmus.retain(|x| x.ts >= 0 && x.ts <= (range.duration() as i64) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if danmus.is_empty() {
|
||||
@@ -587,8 +613,17 @@ impl RecorderManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_archives(&self, room_id: u64) -> Result<Vec<RecordRow>, RecorderManagerError> {
|
||||
Ok(self.db.get_records(room_id).await?)
|
||||
pub async fn get_archive_disk_usage(&self) -> Result<u64, RecorderManagerError> {
|
||||
Ok(self.db.get_record_disk_usage().await?)
|
||||
}
|
||||
|
||||
pub async fn get_archives(
|
||||
&self,
|
||||
room_id: u64,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
) -> Result<Vec<RecordRow>, RecorderManagerError> {
|
||||
Ok(self.db.get_records(room_id, offset, limit).await?)
|
||||
}
|
||||
|
||||
pub async fn get_archive(
|
||||
@@ -599,6 +634,36 @@ impl RecorderManager {
|
||||
Ok(self.db.get_record(room_id, live_id).await?)
|
||||
}
|
||||
|
||||
pub async fn get_archive_subtitle(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
live_id: &str,
|
||||
) -> Result<String, RecorderManagerError> {
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
let recorder = recorder_ref.as_ref();
|
||||
Ok(recorder.get_archive_subtitle(live_id).await?)
|
||||
} else {
|
||||
Err(RecorderManagerError::NotFound { room_id })
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_archive_subtitle(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
live_id: &str,
|
||||
) -> Result<String, RecorderManagerError> {
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
let recorder = recorder_ref.as_ref();
|
||||
Ok(recorder.generate_archive_subtitle(live_id).await?)
|
||||
} else {
|
||||
Err(RecorderManagerError::NotFound { room_id })
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_archive(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
|
||||
@@ -18,7 +18,70 @@ pub enum SubtitleGeneratorType {
|
||||
pub struct GenerateResult {
|
||||
pub generator_type: SubtitleGeneratorType,
|
||||
pub subtitle_id: String,
|
||||
pub subtitle_content: String,
|
||||
pub subtitle_content: Vec<srtparse::Item>,
|
||||
}
|
||||
|
||||
impl GenerateResult {
|
||||
pub fn concat(&mut self, other: &GenerateResult, offset_seconds: u64) {
|
||||
let mut to_extend = other.subtitle_content.clone();
|
||||
let last_item_index = self.subtitle_content.len();
|
||||
for (i, item) in to_extend.iter_mut().enumerate() {
|
||||
item.pos = last_item_index + i + 1;
|
||||
item.start_time = add_offset(&item.start_time, offset_seconds);
|
||||
item.end_time = add_offset(&item.end_time, offset_seconds);
|
||||
}
|
||||
self.subtitle_content.extend(to_extend);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_offset(item: &srtparse::Time, offset: u64) -> srtparse::Time {
|
||||
let mut total_seconds = item.seconds + offset;
|
||||
let mut total_minutes = item.minutes;
|
||||
let mut total_hours = item.hours;
|
||||
|
||||
// Handle seconds overflow (>= 60)
|
||||
if total_seconds >= 60 {
|
||||
let additional_minutes = total_seconds / 60;
|
||||
total_seconds %= 60;
|
||||
total_minutes += additional_minutes;
|
||||
}
|
||||
|
||||
// Handle minutes overflow (>= 60)
|
||||
if total_minutes >= 60 {
|
||||
let additional_hours = total_minutes / 60;
|
||||
total_minutes %= 60;
|
||||
total_hours += additional_hours;
|
||||
}
|
||||
|
||||
srtparse::Time {
|
||||
hours: total_hours,
|
||||
minutes: total_minutes,
|
||||
seconds: total_seconds,
|
||||
milliseconds: item.milliseconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_to_srt(item: &srtparse::Item) -> String {
|
||||
let start_time = format!(
|
||||
"{:02}:{:02}:{:02},{:03}",
|
||||
item.start_time.hours,
|
||||
item.start_time.minutes,
|
||||
item.start_time.seconds,
|
||||
item.start_time.milliseconds
|
||||
);
|
||||
|
||||
let end_time = format!(
|
||||
"{:02}:{:02}:{:02},{:03}",
|
||||
item.end_time.hours,
|
||||
item.end_time.minutes,
|
||||
item.end_time.seconds,
|
||||
item.end_time.milliseconds
|
||||
);
|
||||
|
||||
format!(
|
||||
"{}\n{} --> {}\n{}\n\n",
|
||||
item.pos, start_time, end_time, item.text
|
||||
)
|
||||
}
|
||||
|
||||
impl SubtitleGeneratorType {
|
||||
@@ -43,8 +106,8 @@ impl SubtitleGeneratorType {
|
||||
pub trait SubtitleGenerator {
|
||||
async fn generate_subtitle(
|
||||
&self,
|
||||
reporter: &impl ProgressReporterTrait,
|
||||
video_path: &Path,
|
||||
output_path: &Path,
|
||||
reporter: Option<&impl ProgressReporterTrait>,
|
||||
audio_path: &Path,
|
||||
language_hint: &str,
|
||||
) -> Result<GenerateResult, String>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::{
|
||||
};
|
||||
use async_std::sync::{Arc, RwLock};
|
||||
use std::path::Path;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
use super::SubtitleGenerator;
|
||||
@@ -37,9 +36,9 @@ pub async fn new(model: &Path, prompt: &str) -> Result<WhisperCPP, String> {
|
||||
impl SubtitleGenerator for WhisperCPP {
|
||||
async fn generate_subtitle(
|
||||
&self,
|
||||
reporter: &impl ProgressReporterTrait,
|
||||
reporter: Option<&impl ProgressReporterTrait>,
|
||||
audio_path: &Path,
|
||||
output_path: &Path,
|
||||
language_hint: &str,
|
||||
) -> Result<GenerateResult, String> {
|
||||
log::info!("Generating subtitle for {:?}", audio_path);
|
||||
let start_time = std::time::Instant::now();
|
||||
@@ -55,8 +54,8 @@ impl SubtitleGenerator for WhisperCPP {
|
||||
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
|
||||
// and set the language to translate to to auto
|
||||
params.set_language(None);
|
||||
// and set the language
|
||||
params.set_language(Some(language_hint));
|
||||
params.set_initial_prompt(self.prompt.as_str());
|
||||
|
||||
// we also explicitly disable anything that prints to stdout
|
||||
@@ -71,7 +70,9 @@ impl SubtitleGenerator for WhisperCPP {
|
||||
|
||||
let mut inter_samples = vec![Default::default(); samples.len()];
|
||||
|
||||
reporter.update("处理音频中");
|
||||
if let Some(reporter) = reporter {
|
||||
reporter.update("处理音频中");
|
||||
}
|
||||
if let Err(e) = whisper_rs::convert_integer_to_float_audio(&samples, &mut inter_samples) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
@@ -83,17 +84,14 @@ impl SubtitleGenerator for WhisperCPP {
|
||||
|
||||
let samples = samples.unwrap();
|
||||
|
||||
reporter.update("生成字幕中");
|
||||
if let Some(reporter) = reporter {
|
||||
reporter.update("生成字幕中");
|
||||
}
|
||||
if let Err(e) = state.full(params, &samples[..]) {
|
||||
log::error!("failed to run model: {}", e);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
// open the output file
|
||||
let mut output_file = tokio::fs::File::create(output_path).await.map_err(|e| {
|
||||
log::error!("failed to create output file: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
// fetch the results
|
||||
let num_segments = state.full_n_segments().map_err(|e| e.to_string())?;
|
||||
let mut subtitle = String::new();
|
||||
@@ -105,8 +103,14 @@ impl SubtitleGenerator for WhisperCPP {
|
||||
let format_time = |timestamp: f64| {
|
||||
let hours = (timestamp / 3600.0).floor();
|
||||
let minutes = ((timestamp - hours * 3600.0) / 60.0).floor();
|
||||
let seconds = timestamp - hours * 3600.0 - minutes * 60.0;
|
||||
format!("{:02}:{:02}:{:06.3}", hours, minutes, seconds).replace(".", ",")
|
||||
let seconds = (timestamp - hours * 3600.0 - minutes * 60.0).floor();
|
||||
let milliseconds = ((timestamp - hours * 3600.0 - minutes * 60.0 - seconds)
|
||||
* 1000.0)
|
||||
.floor() as u32;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02},{:03}",
|
||||
hours, minutes, seconds, milliseconds
|
||||
)
|
||||
};
|
||||
|
||||
let line = format!(
|
||||
@@ -120,21 +124,15 @@ impl SubtitleGenerator for WhisperCPP {
|
||||
subtitle.push_str(&line);
|
||||
}
|
||||
|
||||
output_file
|
||||
.write_all(subtitle.as_bytes())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("failed to write to output file: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
log::info!("Subtitle generated: {:?}", output_path);
|
||||
log::info!("Time taken: {} seconds", start_time.elapsed().as_secs_f64());
|
||||
|
||||
let subtitle_content = srtparse::from_str(&subtitle)
|
||||
.map_err(|e| format!("Failed to parse subtitle: {}", e))?;
|
||||
|
||||
Ok(GenerateResult {
|
||||
generator_type: SubtitleGeneratorType::Whisper,
|
||||
subtitle_id: "".to_string(),
|
||||
subtitle_content: subtitle,
|
||||
subtitle_content,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -185,10 +183,9 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let audio_path = Path::new("tests/audio/test.wav");
|
||||
let output_path = Path::new("tests/audio/test.srt");
|
||||
let reporter = MockReporter::new();
|
||||
let result = whisper
|
||||
.generate_subtitle(&reporter, audio_path, output_path)
|
||||
.generate_subtitle(Some(&reporter), audio_path, "auto")
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
println!("Error: {}", e);
|
||||
|
||||
@@ -3,7 +3,6 @@ use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::{
|
||||
progress_reporter::ProgressReporterTrait,
|
||||
@@ -55,15 +54,17 @@ pub async fn new(
|
||||
impl SubtitleGenerator for WhisperOnline {
|
||||
async fn generate_subtitle(
|
||||
&self,
|
||||
reporter: &impl ProgressReporterTrait,
|
||||
reporter: Option<&impl ProgressReporterTrait>,
|
||||
audio_path: &Path,
|
||||
output_path: &Path,
|
||||
language_hint: &str,
|
||||
) -> Result<GenerateResult, String> {
|
||||
log::info!("Generating subtitle online for {:?}", audio_path);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Read audio file
|
||||
reporter.update("读取音频文件中");
|
||||
if let Some(reporter) = reporter {
|
||||
reporter.update("读取音频文件中");
|
||||
}
|
||||
let audio_data = fs::read(audio_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read audio file: {}", e))?;
|
||||
@@ -100,6 +101,8 @@ impl SubtitleGenerator for WhisperOnline {
|
||||
.text("response_format", "verbose_json")
|
||||
.text("temperature", "0.0");
|
||||
|
||||
form = form.text("language", language_hint.to_string());
|
||||
|
||||
if let Some(prompt) = self.prompt.clone() {
|
||||
form = form.text("prompt", prompt);
|
||||
}
|
||||
@@ -111,9 +114,11 @@ impl SubtitleGenerator for WhisperOnline {
|
||||
req_builder = req_builder.header("Authorization", format!("Bearer {}", api_key));
|
||||
}
|
||||
|
||||
reporter.update("上传音频中");
|
||||
if let Some(reporter) = reporter {
|
||||
reporter.update("上传音频中");
|
||||
}
|
||||
let response = req_builder
|
||||
.timeout(std::time::Duration::from_secs(30 * 60)) // 30 minutes timeout
|
||||
.timeout(std::time::Duration::from_secs(3 * 60)) // 3 minutes timeout
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
@@ -122,6 +127,7 @@ impl SubtitleGenerator for WhisperOnline {
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
log::error!("API request failed with status {}: {}", status, error_text);
|
||||
return Err(format!(
|
||||
"API request failed with status {}: {}",
|
||||
status, error_text
|
||||
@@ -151,8 +157,14 @@ impl SubtitleGenerator for WhisperOnline {
|
||||
let format_time = |timestamp: f64| {
|
||||
let hours = (timestamp / 3600.0).floor();
|
||||
let minutes = ((timestamp - hours * 3600.0) / 60.0).floor();
|
||||
let seconds = timestamp - hours * 3600.0 - minutes * 60.0;
|
||||
format!("{:02}:{:02}:{:06.3}", hours, minutes, seconds).replace(".", ",")
|
||||
let seconds = (timestamp - hours * 3600.0 - minutes * 60.0).floor();
|
||||
let milliseconds = ((timestamp - hours * 3600.0 - minutes * 60.0 - seconds)
|
||||
* 1000.0)
|
||||
.floor() as u32;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02},{:03}",
|
||||
hours, minutes, seconds, milliseconds
|
||||
)
|
||||
};
|
||||
|
||||
let line = format!(
|
||||
@@ -166,23 +178,15 @@ impl SubtitleGenerator for WhisperOnline {
|
||||
subtitle.push_str(&line);
|
||||
}
|
||||
|
||||
// Write subtitle to file
|
||||
let mut output_file = fs::File::create(output_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||
|
||||
output_file
|
||||
.write_all(subtitle.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write subtitle file: {}", e))?;
|
||||
|
||||
log::info!("Online subtitle generated: {:?}", output_path);
|
||||
log::info!("Time taken: {} seconds", start_time.elapsed().as_secs_f64());
|
||||
|
||||
let subtitle_content = srtparse::from_str(&subtitle)
|
||||
.map_err(|e| format!("Failed to parse subtitle: {}", e))?;
|
||||
|
||||
Ok(GenerateResult {
|
||||
generator_type: SubtitleGeneratorType::WhisperOnline,
|
||||
subtitle_id: "".to_string(),
|
||||
subtitle_content: subtitle,
|
||||
subtitle_content,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -230,14 +234,14 @@ mod tests {
|
||||
let result = result.unwrap();
|
||||
let result = result
|
||||
.generate_subtitle(
|
||||
&MockReporter::new(),
|
||||
Some(&MockReporter::new()),
|
||||
Path::new("tests/audio/test.wav"),
|
||||
Path::new("tests/audio/test.srt"),
|
||||
"auto",
|
||||
)
|
||||
.await;
|
||||
println!("{:?}", result);
|
||||
assert!(result.is_ok());
|
||||
let result = result.unwrap();
|
||||
println!("{}", result.subtitle_content);
|
||||
println!("{:?}", result.subtitle_content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"plugins": {
|
||||
"sql": {
|
||||
"preload": ["sqlite:data_v2.db"]
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["bsr"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
|
||||
@@ -5,11 +5,47 @@
|
||||
import Setting from "./page/Setting.svelte";
|
||||
import Account from "./page/Account.svelte";
|
||||
import About from "./page/About.svelte";
|
||||
import { log } from "./lib/invoker";
|
||||
import { log, onOpenUrl } from "./lib/invoker";
|
||||
import Clip from "./page/Clip.svelte";
|
||||
import Task from "./page/Task.svelte";
|
||||
import AI from "./page/AI.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let active = "总览";
|
||||
|
||||
onMount(async () => {
|
||||
await onOpenUrl((urls: string[]) => {
|
||||
console.log("Received Deep Link:", urls);
|
||||
if (urls.length > 0) {
|
||||
const url = urls[0];
|
||||
// extract platform and room_id from url
|
||||
// url example:
|
||||
// bsr://live.bilibili.com/167537?live_from=85001&spm_id_from=333.1365.live_users.item.click
|
||||
// bsr://live.douyin.com/200525029536
|
||||
|
||||
let platform = "";
|
||||
let room_id = "";
|
||||
|
||||
if (url.startsWith("bsr://live.bilibili.com/")) {
|
||||
// 1. remove bsr://live.bilibili.com/
|
||||
// 2. remove all query params
|
||||
room_id = url.replace("bsr://live.bilibili.com/", "").split("?")[0];
|
||||
platform = "bilibili";
|
||||
}
|
||||
|
||||
if (url.startsWith("bsr://live.douyin.com/")) {
|
||||
room_id = url.replace("bsr://live.douyin.com/", "").split("?")[0];
|
||||
platform = "douyin";
|
||||
}
|
||||
|
||||
if (platform && room_id) {
|
||||
// switch to room page
|
||||
active = "直播间";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
log.info("App loaded");
|
||||
</script>
|
||||
|
||||
@@ -36,6 +72,9 @@
|
||||
<div class="page" class:visible={active == "任务"}>
|
||||
<Task />
|
||||
</div>
|
||||
<div class="page" class:visible={active == "助手"}>
|
||||
<AI />
|
||||
</div>
|
||||
<div class="page" class:visible={active == "账号"}>
|
||||
<Account />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "./lib/invoker";
|
||||
import { invoke, convertFileSrc, convertCoverSrc } from "./lib/invoker";
|
||||
import { onMount } from "svelte";
|
||||
import VideoPreview from "./lib/VideoPreview.svelte";
|
||||
import type { Config, VideoItem } from "./lib/interface";
|
||||
import { convertFileSrc, set_title } from "./lib/invoker";
|
||||
import { set_title } from "./lib/invoker";
|
||||
|
||||
let video: VideoItem | null = null;
|
||||
let videos: any[] = [];
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
invoke("get_config").then((c) => {
|
||||
config = c as Config;
|
||||
console.log(config);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
@@ -27,22 +26,23 @@
|
||||
// update window title to file name
|
||||
set_title((videoData as VideoItem).file);
|
||||
// 获取房间下的所有视频列表
|
||||
if (roomId) {
|
||||
videos = (
|
||||
(await invoke("get_videos", { roomId: roomId })) as VideoItem[]
|
||||
).map((v) => {
|
||||
if (roomId !== null && roomId !== undefined) {
|
||||
const videoList = (await invoke("get_videos", { roomId: roomId })) as VideoItem[];
|
||||
videos = await Promise.all(videoList.map(async (v) => {
|
||||
return {
|
||||
id: v.id,
|
||||
value: v.id,
|
||||
name: v.file,
|
||||
file: convertFileSrc(config.output + "/" + v.file),
|
||||
file: await convertFileSrc(v.file),
|
||||
cover: v.cover,
|
||||
};
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// find video in videos
|
||||
video = videos.find((v) => v.id === parseInt(videoId));
|
||||
let new_video = videos.find((v) => v.id === parseInt(videoId));
|
||||
|
||||
handleVideoChange(new_video);
|
||||
|
||||
// 显示视频预览
|
||||
showVideoPreview = true;
|
||||
@@ -50,31 +50,40 @@
|
||||
console.error("Failed to load video:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(video);
|
||||
});
|
||||
|
||||
async function handleVideoChange(newVideo: VideoItem) {
|
||||
if (newVideo) {
|
||||
// get cover from video
|
||||
const cover = await invoke("get_video_cover", { id: newVideo.id }) as string;
|
||||
|
||||
// 对于非空的封面路径,使用convertCoverSrc转换
|
||||
if (cover && cover.trim() !== "") {
|
||||
newVideo.cover = await convertCoverSrc(cover, newVideo.id);
|
||||
} else {
|
||||
newVideo.cover = "";
|
||||
}
|
||||
}
|
||||
video = newVideo;
|
||||
}
|
||||
|
||||
async function handleVideoListUpdate() {
|
||||
if (roomId) {
|
||||
if (roomId !== null && roomId !== undefined) {
|
||||
const videosData = await invoke("get_videos", { roomId });
|
||||
videos = (videosData as VideoItem[]).map((v) => {
|
||||
videos = await Promise.all((videosData as VideoItem[]).map(async (v) => {
|
||||
return {
|
||||
id: v.id,
|
||||
value: v.id,
|
||||
name: v.file,
|
||||
file: convertFileSrc(config.output + "/" + v.file),
|
||||
cover: v.cover,
|
||||
file: await convertFileSrc(v.file),
|
||||
cover: v.cover, // 这里保持原样,因为get_videos返回的是VideoNoCover类型,不包含完整封面数据
|
||||
};
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showVideoPreview && video && roomId}
|
||||
{#if showVideoPreview && video && (roomId !== null && roomId !== undefined)}
|
||||
<VideoPreview
|
||||
bind:show={showVideoPreview}
|
||||
{video}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
set_title,
|
||||
TAURI_ENV,
|
||||
convertFileSrc,
|
||||
convertCoverSrc,
|
||||
listen,
|
||||
log,
|
||||
} from "./lib/invoker";
|
||||
@@ -36,11 +37,11 @@
|
||||
|
||||
invoke("get_config").then((c) => {
|
||||
config = c as Config;
|
||||
console.log(config);
|
||||
});
|
||||
|
||||
let current_clip_event_id = null;
|
||||
let danmu_enabled = false;
|
||||
let fix_encoding = false;
|
||||
|
||||
// 弹幕相关变量
|
||||
let danmu_records: DanmuEntry[] = [];
|
||||
@@ -138,28 +139,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function format_time(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
const seconds = date.getSeconds().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
// 格式化时间(ts 为毫秒)
|
||||
function format_time(milliseconds: number): string {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const remaining_seconds = (seconds % 60).toString().padStart(2, "0");
|
||||
const remaining_minutes = (minutes % 60).toString().padStart(2, "0");
|
||||
return `${hours}:${remaining_minutes}:${remaining_seconds}`;
|
||||
}
|
||||
|
||||
// 将时长(单位: 秒)格式化为 "X小时 Y分 Z秒"
|
||||
function format_duration_seconds(totalSecondsFloat: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(totalSecondsFloat));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const parts = [] as string[];
|
||||
if (hours > 0) parts.push(`${hours}小时`);
|
||||
if (minutes > 0) parts.push(`${minutes}分`);
|
||||
parts.push(`${seconds}秒`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// 跳转到弹幕时间点
|
||||
function seek_to_danmu(danmu: DanmuEntry) {
|
||||
if (player) {
|
||||
const time_in_seconds = danmu.ts / 1000 - global_offset;
|
||||
const time_in_seconds = danmu.ts / 1000;
|
||||
player.seek(time_in_seconds);
|
||||
}
|
||||
}
|
||||
|
||||
const update_listener = listen<ProgressUpdate>(`progress-update`, (e) => {
|
||||
console.log("progress-update event", e.payload.id);
|
||||
let event_id = e.payload.id;
|
||||
if (event_id === current_clip_event_id) {
|
||||
update_clip_prompt(e.payload.content);
|
||||
@@ -168,10 +181,8 @@
|
||||
const finished_listener = listen<ProgressFinished>(
|
||||
`progress-finished`,
|
||||
(e) => {
|
||||
console.log("progress-finished event", e.payload.id);
|
||||
let event_id = e.payload.id;
|
||||
if (event_id === current_clip_event_id) {
|
||||
console.log("clip event finished", event_id);
|
||||
update_clip_prompt(`生成切片`);
|
||||
if (!e.payload.success) {
|
||||
alert("请检查 ffmpeg 是否配置正确:" + e.payload.message);
|
||||
@@ -205,7 +216,7 @@
|
||||
end = parseFloat(localStorage.getItem(`${live_id}_end`)) - focus_start;
|
||||
}
|
||||
|
||||
console.log("Loaded start and end", start, end);
|
||||
|
||||
|
||||
function generateCover() {
|
||||
const video = document.getElementById("video") as HTMLVideoElement;
|
||||
@@ -254,7 +265,6 @@
|
||||
|
||||
invoke("get_archive", { roomId: room_id, liveId: live_id }).then(
|
||||
(a: RecordItem) => {
|
||||
console.log(a);
|
||||
archive = a;
|
||||
set_title(`[${room_id}]${archive.title}`);
|
||||
}
|
||||
@@ -269,17 +279,16 @@
|
||||
}
|
||||
|
||||
async function get_video_list() {
|
||||
videos = (
|
||||
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
|
||||
).map((v) => {
|
||||
const videoList = (await invoke("get_videos", { roomId: room_id })) as VideoItem[];
|
||||
videos = await Promise.all(videoList.map(async (v) => {
|
||||
return {
|
||||
id: v.id,
|
||||
value: v.id,
|
||||
name: v.file,
|
||||
file: convertFileSrc(config.output + "/" + v.file),
|
||||
file: await convertFileSrc(v.file),
|
||||
cover: v.cover,
|
||||
};
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async function find_video(e) {
|
||||
@@ -292,10 +301,10 @@
|
||||
return v.value == id;
|
||||
});
|
||||
if (target_video) {
|
||||
target_video.cover = await invoke("get_video_cover", { id: id });
|
||||
const rawCover = await invoke("get_video_cover", { id: id }) as string;
|
||||
target_video.cover = await convertCoverSrc(rawCover, id);
|
||||
}
|
||||
selected_video = target_video;
|
||||
console.log("video selected", videos, selected_video, e, id);
|
||||
}
|
||||
|
||||
async function generate_clip() {
|
||||
@@ -323,13 +332,15 @@
|
||||
platform: platform,
|
||||
cover: new_cover,
|
||||
live_id: live_id,
|
||||
x: Math.floor(focus_start + start),
|
||||
y: Math.floor(focus_start + end),
|
||||
range: {
|
||||
start: focus_start + start,
|
||||
end: focus_start + end,
|
||||
},
|
||||
danmu: danmu_enabled,
|
||||
offset: global_offset,
|
||||
local_offset:
|
||||
parseInt(localStorage.getItem(`local_offset:${live_id}`) || "0", 10) ||
|
||||
0,
|
||||
fix_encoding,
|
||||
})) as VideoItem;
|
||||
await get_video_list();
|
||||
video_selected = new_video.id;
|
||||
@@ -675,41 +686,78 @@
|
||||
|
||||
<!-- Clip Confirmation Dialog -->
|
||||
{#if show_clip_confirm}
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-[#1c1c1e] rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-medium text-white mb-4">确认生成切片</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-300">
|
||||
<p>切片时长: {(end - start).toFixed(2)} 秒</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="confirm-danmu-checkbox"
|
||||
bind:checked={danmu_enabled}
|
||||
class="w-4 h-4 text-[#0A84FF] bg-[#2c2c2e] border-gray-800 rounded focus:ring-[#0A84FF] focus:ring-offset-[#1c1c1e]"
|
||||
/>
|
||||
<label for="confirm-danmu-checkbox" class="text-sm text-gray-300"
|
||||
>压制弹幕</label
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-md"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="关闭对话框"
|
||||
on:click={() => (show_clip_confirm = false)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
show_clip_confirm = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
class="relative mx-4 w-full max-w-md rounded-2xl bg-[#1c1c1e] border border-white/10 shadow-2xl ring-1 ring-black/5"
|
||||
>
|
||||
<div class="p-5">
|
||||
<h3 class="text-[17px] font-semibold text-white">确认生成切片</h3>
|
||||
<p class="mt-1 text-[13px] text-white/70">请确认以下设置后继续</p>
|
||||
|
||||
<div class="mt-4 rounded-xl bg-[#2c2c2e] border border-white/10 p-3">
|
||||
<div class="text-[13px] text-white/80">切片时长</div>
|
||||
<div
|
||||
class="mt-0.5 text-[22px] font-semibold tracking-tight text-white"
|
||||
>
|
||||
{format_duration_seconds(end - start)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
on:click={() => (show_clip_confirm = false)}
|
||||
class="px-4 py-2 text-gray-300 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
on:click={confirm_generate_clip}
|
||||
class="px-4 py-2 bg-[#0A84FF] text-white rounded-lg hover:bg-[#0A84FF]/90 transition-colors duration-200"
|
||||
>
|
||||
确认生成
|
||||
</button>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
<label class="flex items-center gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="confirm-danmu-checkbox"
|
||||
bind:checked={danmu_enabled}
|
||||
class="h-4 w-4 rounded border-white/30 bg-[#2c2c2e] text-[#0A84FF] accent-[#0A84FF] focus:outline-none focus:ring-2 focus:ring-[#0A84FF]/40"
|
||||
/>
|
||||
<span class="text-[13px] text-white/80">压制弹幕</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="confirm-fix-encoding-checkbox"
|
||||
bind:checked={fix_encoding}
|
||||
class="h-4 w-4 rounded border-white/30 bg-[#2c2c2e] text-[#0A84FF] accent-[#0A84FF] focus:outline-none focus:ring-2 focus:ring-[#0A84FF]/40"
|
||||
/>
|
||||
<span class="text-[13px] text-white/80">修复编码</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-end gap-2 rounded-b-2xl border-t border-white/10 bg-[#111113] px-5 py-3"
|
||||
>
|
||||
<button
|
||||
on:click={() => (show_clip_confirm = false)}
|
||||
class="px-3.5 py-2 text-[13px] rounded-lg border border-white/20 text-white/90 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
on:click={confirm_generate_clip}
|
||||
class="px-3.5 py-2 text-[13px] rounded-lg bg-[#0A84FF] text-white shadow-[inset_0_1px_0_rgba(255,255,255,.15)] hover:bg-[#0A84FF]/90 transition-colors"
|
||||
>
|
||||
确认生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
214
src/lib/AIMessage.svelte
Normal file
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from "lucide-svelte";
|
||||
import { AIMessage } from "@langchain/core/messages";
|
||||
import { marked } from "marked";
|
||||
|
||||
export let message: AIMessage;
|
||||
export let formatTime: (date: Date) => string;
|
||||
export let onToolCallConfirm: ((toolCall: any) => void) | undefined =
|
||||
undefined;
|
||||
export let onToolCallReject: ((toolCall: any) => void) | undefined =
|
||||
undefined;
|
||||
export let toolCallState: 'confirmed' | 'rejected' | 'none' = 'none';
|
||||
export let isSensitiveToolCall: boolean = false;
|
||||
|
||||
// 检查是否被内容过滤
|
||||
$: isContentFiltered = message.response_metadata?.finish_reason === "content_filter";
|
||||
|
||||
// 获取消息时间戳,如果没有则使用当前时间
|
||||
$: messageTime = message.additional_kwargs?.timestamp
|
||||
? new Date(message.additional_kwargs.timestamp as string)
|
||||
: new Date();
|
||||
|
||||
// 将 Markdown 转换为 HTML
|
||||
$: htmlContent = marked(
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: Array.isArray(message.content)
|
||||
? message.content
|
||||
.map((c) => (typeof c === "string" ? c : JSON.stringify(c)))
|
||||
.join("\n")
|
||||
: JSON.stringify(message.content)
|
||||
);
|
||||
|
||||
// 检查消息是否包含表格
|
||||
$: hasTable = message.content && typeof message.content === 'string' &&
|
||||
(message.content.includes('|') || message.content.includes('---') ||
|
||||
message.content.includes('|--') || message.content.includes('| -'));
|
||||
|
||||
// 处理工具调用确认
|
||||
function handleToolCallConfirm(toolCall: any) {
|
||||
if (onToolCallConfirm) {
|
||||
onToolCallConfirm(toolCall);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用拒绝
|
||||
function handleToolCallReject(toolCall: any) {
|
||||
if (onToolCallReject) {
|
||||
onToolCallReject(toolCall);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<div class="flex items-start space-x-3" class:max-w-2xl={!hasTable} class:max-w-4xl={hasTable}>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<Bot class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
小轴
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTime(messageTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-2xl px-4 py-3 shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<!-- 内容过滤警告 -->
|
||||
{#if isContentFiltered}
|
||||
<div class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg">
|
||||
<div class="flex items-center space-x-2">
|
||||
<AlertTriangle class="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<span class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
内容被过滤
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
由于内容安全策略,部分回复内容可能已被过滤。请尝试重新表述您的问题。
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="text-gray-900 dark:text-white text-sm leading-relaxed prose prose-sm max-w-none [&_.prose]:bg-transparent [&_.prose_*]:bg-transparent [&_p]:bg-transparent [&_div]:bg-transparent [&_span]:bg-transparent [&_code]:bg-gray-100 dark:bg-gray-700 [&_pre]:bg-gray-100 dark:bg-gray-700 [&_blockquote]:bg-transparent [&_ul]:bg-transparent [&_ol]:bg-transparent [&_li]:bg-transparent [&_h1]:bg-transparent [&_h2]:bg-transparent [&_h3]:bg-transparent [&_h4]:bg-transparent [&_h5]:bg-transparent [&_h6]:bg-transparent [&_p]:m-0 [&_p]:p-0 [&_div]:m-0 [&_div]:p-0 [&_ul]:m-0 [&_ul]:p-0 [&_ol]:m-0 [&_ol]:p-0 [&_li]:m-0 [&_li]:p-0 [&_li]:mb-0 [&_li]:mt-0 [&_h1]:m-0 [&_h1]:p-0 [&_h2]:m-0 [&_h2]:p-0 [&_h3]:m-0 [&_h3]:p-0 [&_h4]:m-0 [&_h4]:p-0 [&_h5]:m-0 [&_h5]:p-0 [&_h6]:m-0 [&_h6]:p-0 [&_blockquote]:m-0 [&_blockquote]:p-0"
|
||||
>
|
||||
{#if hasTable}
|
||||
<div class="table-container">
|
||||
{@html htmlContent}
|
||||
</div>
|
||||
{:else}
|
||||
{@html htmlContent}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if message.tool_calls && message.tool_calls.length > 0}
|
||||
<div class="space-y-2 mt-3">
|
||||
{#each message.tool_calls as tool_call}
|
||||
<div
|
||||
class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg p-3"
|
||||
>
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<div
|
||||
class="w-5 h-5 rounded bg-blue-500 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-medium text-blue-700 dark:text-blue-300"
|
||||
>
|
||||
工具调用: {tool_call.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if tool_call.args && Object.keys(tool_call.args).length > 0}
|
||||
<div class="mb-2">
|
||||
<div
|
||||
class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1"
|
||||
>
|
||||
参数:
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded p-2">
|
||||
<pre
|
||||
class="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">{JSON.stringify(
|
||||
tool_call.args,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tool_call.id}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
ID: {tool_call.id}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 工具调用状态或操作按钮 -->
|
||||
{#if isSensitiveToolCall}
|
||||
<div
|
||||
class="flex items-center justify-between mt-3 pt-2 border-t border-blue-200 dark:border-blue-700"
|
||||
>
|
||||
{#if tool_call.id && toolCallState === 'confirmed'}
|
||||
<!-- 显示状态 -->
|
||||
<div class="flex items-center space-x-2 text-green-600 dark:text-green-400">
|
||||
<Check class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">已确认</span>
|
||||
</div>
|
||||
{:else if toolCallState === 'rejected'}
|
||||
<div class="flex items-center space-x-2 text-red-600 dark:text-red-400">
|
||||
<X class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">已拒绝</span>
|
||||
</div>
|
||||
{:else if onToolCallConfirm || onToolCallReject}
|
||||
<!-- 显示操作按钮 -->
|
||||
{#if onToolCallReject}
|
||||
<button
|
||||
on:click={() => handleToolCallReject(tool_call)}
|
||||
class="flex items-center space-x-1 px-4 py-2 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white text-xs font-medium rounded-lg shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
<span>拒绝</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onToolCallConfirm}
|
||||
<button
|
||||
on:click={() => handleToolCallConfirm(tool_call)}
|
||||
class="flex items-center space-x-1 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white text-xs font-medium rounded-lg shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
|
||||
>
|
||||
<Check class="w-3 h-3" />
|
||||
<span>确认</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
Settings,
|
||||
Users,
|
||||
Video,
|
||||
Brain,
|
||||
} from "lucide-svelte";
|
||||
import { hasNewVersion } from "./stores/version";
|
||||
import SidebarItem from "./SidebarItem.svelte";
|
||||
@@ -48,6 +49,11 @@
|
||||
<List class="w-5 h-5" />
|
||||
</div>
|
||||
</SidebarItem>
|
||||
<SidebarItem label="助手" {activeUrl} on:activeChange={navigate}>
|
||||
<div slot="icon">
|
||||
<Brain class="w-5 h-5" />
|
||||
</div>
|
||||
</SidebarItem>
|
||||
<SidebarItem label="账号" {activeUrl} on:activeChange={navigate}>
|
||||
<div slot="icon">
|
||||
<Users class="w-5 h-5" />
|
||||
|
||||
41
src/lib/HumanMessage.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { User } from "lucide-svelte";
|
||||
import { HumanMessage } from "@langchain/core/messages";
|
||||
|
||||
export let message: HumanMessage;
|
||||
export let formatTime: (date: Date) => string;
|
||||
|
||||
// 获取消息时间戳,如果没有则使用当前时间
|
||||
$: messageTime = message.additional_kwargs?.timestamp
|
||||
? new Date(message.additional_kwargs.timestamp as string | number)
|
||||
: new Date();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<div class="flex items-start space-x-3 max-w-2xl">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
你
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTime(messageTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-2xl px-4 py-3 shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="text-gray-900 dark:text-white text-sm leading-relaxed">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-gray-500 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<User class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
399
src/lib/ImportVideoDialog.svelte
Normal file
@@ -0,0 +1,399 @@
|
||||
<script lang="ts">
|
||||
import { invoke, TAURI_ENV, ENDPOINT, listen } from "../lib/invoker";
|
||||
import { Upload, X, CheckCircle } from "lucide-svelte";
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { ProgressUpdate, ProgressFinished } from "./interface";
|
||||
|
||||
export let showDialog = false;
|
||||
export let roomId: number | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let selectedFilePath: string | null = null;
|
||||
let selectedFileName: string = "";
|
||||
let selectedFileSize: number = 0;
|
||||
let videoTitle = "";
|
||||
let importing = false;
|
||||
let uploading = false;
|
||||
let uploadProgress = 0;
|
||||
let dragOver = false;
|
||||
let fileInput: HTMLInputElement;
|
||||
let importProgress = "";
|
||||
let currentImportEventId: string | null = null;
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(sizeInBytes: number): string {
|
||||
if (sizeInBytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const k = 1024;
|
||||
let unitIndex = 0;
|
||||
let size = sizeInBytes;
|
||||
|
||||
// 找到合适的单位
|
||||
while (size >= k && unitIndex < units.length - 1) {
|
||||
size /= k;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// 对于GB以上,显示2位小数;MB显示2位小数;KB及以下显示1位小数
|
||||
const decimals = unitIndex >= 3 ? 2 : (unitIndex >= 2 ? 2 : 1);
|
||||
|
||||
return size.toFixed(decimals) + " " + units[unitIndex];
|
||||
}
|
||||
|
||||
// 进度监听器
|
||||
const progressUpdateListener = listen<ProgressUpdate>('progress-update', (e) => {
|
||||
if (e.payload.id === currentImportEventId) {
|
||||
importProgress = e.payload.content;
|
||||
}
|
||||
});
|
||||
|
||||
const progressFinishedListener = listen<ProgressFinished>('progress-finished', (e) => {
|
||||
if (e.payload.id === currentImportEventId) {
|
||||
if (e.payload.success) {
|
||||
// 导入成功,关闭对话框并刷新列表
|
||||
showDialog = false;
|
||||
selectedFilePath = null;
|
||||
selectedFileName = "";
|
||||
selectedFileSize = 0;
|
||||
videoTitle = "";
|
||||
dispatch("imported");
|
||||
} else {
|
||||
alert("导入失败: " + e.payload.message);
|
||||
}
|
||||
importing = false;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
progressUpdateListener?.then(fn => fn());
|
||||
progressFinishedListener?.then(fn => fn());
|
||||
});
|
||||
|
||||
async function handleFileSelect() {
|
||||
if (TAURI_ENV) {
|
||||
// Tauri模式:使用文件对话框
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{
|
||||
name: '视频文件',
|
||||
extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v', 'webm']
|
||||
}]
|
||||
});
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
await setSelectedFile(selected);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("文件选择失败:", error);
|
||||
}
|
||||
} else {
|
||||
// Web模式:触发文件输入
|
||||
fileInput?.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
// 提前设置文件信息,提升用户体验
|
||||
selectedFileName = file.name;
|
||||
videoTitle = file.name.replace(/\.[^/.]+$/, ""); // 去掉扩展名
|
||||
selectedFileSize = file.size;
|
||||
|
||||
await uploadFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragOver = false;
|
||||
|
||||
if (TAURI_ENV) return; // Tauri模式不支持拖拽
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
// 检查文件类型
|
||||
const allowedTypes = ['video/mp4', 'video/x-msvideo', 'video/quicktime', 'video/x-ms-wmv', 'video/x-flv', 'video/x-m4v', 'video/webm', 'video/x-matroska'];
|
||||
if (allowedTypes.includes(file.type) || file.name.match(/\.(mp4|mkv|avi|mov|wmv|flv|m4v|webm)$/i)) {
|
||||
// 提前设置文件信息,提升用户体验
|
||||
selectedFileName = file.name;
|
||||
videoTitle = file.name.replace(/\.[^/.]+$/, ""); // 去掉扩展名
|
||||
selectedFileSize = file.size;
|
||||
|
||||
await uploadFile(file);
|
||||
} else {
|
||||
alert("请选择支持的视频文件格式 (MP4, MKV, AVI, MOV, WMV, FLV, M4V, WebM)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
uploading = true;
|
||||
uploadProgress = 0;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('roomId', String(roomId || 0));
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// 监听上传进度
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadProgress = Math.round((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理上传完成
|
||||
xhr.addEventListener('load', async () => {
|
||||
if (xhr.status === 200) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
// 使用本地文件信息,更快更准确
|
||||
await setSelectedFile(response.data.filePath, file.size);
|
||||
} else {
|
||||
throw new Error(response.message || '上传失败');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`上传失败: HTTP ${xhr.status}`);
|
||||
}
|
||||
uploading = false;
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
alert("上传失败:网络错误");
|
||||
uploading = false;
|
||||
});
|
||||
|
||||
xhr.open('POST', `${ENDPOINT}/api/upload_file`);
|
||||
xhr.send(formData);
|
||||
|
||||
} catch (error) {
|
||||
console.error("上传失败:", error);
|
||||
alert("上传失败: " + error);
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setSelectedFile(filePath: string, fileSize?: number) {
|
||||
selectedFilePath = filePath;
|
||||
selectedFileName = filePath.split(/[/\\]/).pop() || '';
|
||||
videoTitle = selectedFileName.replace(/\.[^/.]+$/, ""); // 去掉扩展名
|
||||
|
||||
if (fileSize !== undefined) {
|
||||
selectedFileSize = fileSize;
|
||||
} else {
|
||||
// 获取文件大小 (Tauri模式)
|
||||
try {
|
||||
selectedFileSize = await invoke("get_file_size", { filePath });
|
||||
} catch (e) {
|
||||
selectedFileSize = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!selectedFilePath) return;
|
||||
|
||||
importing = true;
|
||||
importProgress = "准备导入...";
|
||||
|
||||
try {
|
||||
const eventId = "import_" + Date.now();
|
||||
currentImportEventId = eventId;
|
||||
|
||||
await invoke("import_external_video", {
|
||||
eventId: eventId,
|
||||
filePath: selectedFilePath,
|
||||
title: videoTitle,
|
||||
originalName: selectedFileName,
|
||||
size: selectedFileSize,
|
||||
roomId: roomId || 0
|
||||
});
|
||||
|
||||
// 注意:成功处理移到了progressFinishedListener中
|
||||
} catch (error) {
|
||||
console.error("导入失败:", error);
|
||||
alert("导入失败: " + error);
|
||||
importing = false;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
showDialog = false;
|
||||
selectedFilePath = null;
|
||||
selectedFileName = "";
|
||||
selectedFileSize = 0;
|
||||
videoTitle = "";
|
||||
uploading = false;
|
||||
uploadProgress = 0;
|
||||
importing = false;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (!TAURI_ENV) {
|
||||
dragOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOver = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 隐藏的文件输入 -->
|
||||
{#if !TAURI_ENV}
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style="display: none"
|
||||
on:change={handleFileInputChange}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showDialog}
|
||||
<div class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-[#323234] rounded-xl shadow-xl w-full max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">导入外部视频</h3>
|
||||
<button on:click={closeDialog} class="text-gray-400 hover:text-gray-600">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 文件选择区域 -->
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors {
|
||||
dragOver ? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20' :
|
||||
'border-gray-300 dark:border-gray-600'
|
||||
}"
|
||||
on:dragover={handleDragOver}
|
||||
on:dragleave={handleDragLeave}
|
||||
on:drop={handleDrop}
|
||||
>
|
||||
{#if uploading}
|
||||
<!-- 上传进度 -->
|
||||
<div class="space-y-4">
|
||||
<Upload class="w-12 h-12 text-blue-500 mx-auto animate-bounce" />
|
||||
<p class="text-sm text-gray-900 dark:text-white font-medium">上传中...</p>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="bg-blue-500 h-2 rounded-full transition-all" style="width: {uploadProgress}%"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">{uploadProgress}%</p>
|
||||
</div>
|
||||
{:else if selectedFilePath}
|
||||
<!-- 已选择文件 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-center">
|
||||
<CheckCircle class="w-12 h-12 text-green-500 mx-auto" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-900 dark:text-white font-medium">{selectedFileName}</p>
|
||||
<p class="text-xs text-gray-500">大小: {formatFileSize(selectedFileSize)}</p>
|
||||
<p class="text-xs text-gray-400 break-all" title={selectedFilePath}>{selectedFilePath}</p>
|
||||
<button
|
||||
on:click={() => {
|
||||
selectedFilePath = null;
|
||||
selectedFileName = "";
|
||||
selectedFileSize = 0;
|
||||
videoTitle = "";
|
||||
}}
|
||||
class="text-sm text-red-500 hover:text-red-700"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 选择文件提示 -->
|
||||
<div class="space-y-4">
|
||||
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
{#if TAURI_ENV}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
点击按钮选择视频文件
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
拖拽视频文件到此处,或点击按钮选择文件
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500">
|
||||
支持 MP4, MKV, AVI, MOV, WMV, FLV, M4V, WebM 格式
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !uploading && !selectedFilePath}
|
||||
<button
|
||||
on:click={handleFileSelect}
|
||||
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
{TAURI_ENV ? '选择文件' : '选择或拖拽文件'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 视频信息编辑 -->
|
||||
{#if selectedFilePath}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="video-title-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
视频标题
|
||||
</label>
|
||||
<input
|
||||
id="video-title-input"
|
||||
type="text"
|
||||
bind:value={videoTitle}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
placeholder="输入视频标题"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 - 固定在底部 -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-[#2a2a2c]">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
on:click={closeDialog}
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
on:click={startImport}
|
||||
disabled={!selectedFilePath || importing || !videoTitle.trim() || uploading}
|
||||
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
{#if importing}
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{importing ? (importProgress || "导入中...") : "开始导入"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -142,8 +142,10 @@
|
||||
}
|
||||
|
||||
if (TAURI_ENV) {
|
||||
console.log("register tauri network plugin");
|
||||
shaka.net.NetworkingEngine.registerScheme("http", tauriNetworkPlugin);
|
||||
shaka.net.NetworkingEngine.registerScheme("https", tauriNetworkPlugin);
|
||||
shaka.net.NetworkingEngine.registerScheme("tauri", tauriNetworkPlugin);
|
||||
}
|
||||
|
||||
async function update_stream_list() {
|
||||
@@ -297,7 +299,7 @@
|
||||
}
|
||||
|
||||
const cur = Math.floor(
|
||||
(video.currentTime + global_offset + focus_start + local_offset) * 1000
|
||||
(video.currentTime + focus_start + local_offset) * 1000
|
||||
);
|
||||
|
||||
let danmus = danmu_records.filter((v) => {
|
||||
@@ -370,17 +372,30 @@
|
||||
|
||||
// listen to danmaku event
|
||||
await listen("danmu:" + room_id, (event: { payload: DanmuEntry }) => {
|
||||
if (global_offset == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.payload.ts < global_offset * 1000) {
|
||||
log.error("invalid danmu ts:", event.payload.ts, global_offset);
|
||||
return;
|
||||
}
|
||||
|
||||
let danmu_record = {
|
||||
...event.payload,
|
||||
ts: event.payload.ts - global_offset * 1000,
|
||||
};
|
||||
// if not enabled or playback is not keep up with live, ignore the danmaku
|
||||
if (!danmu_enabled || get_total() - video.currentTime > 5) {
|
||||
danmu_records = [...danmu_records, event.payload];
|
||||
danmu_records = [...danmu_records, danmu_record];
|
||||
return;
|
||||
}
|
||||
if (Object.keys(danmu_displayed).length > 1000) {
|
||||
danmu_displayed = {};
|
||||
}
|
||||
danmu_displayed[event.payload.ts] = true;
|
||||
danmu_records = [...danmu_records, event.payload];
|
||||
danmu_handler(event.payload.content);
|
||||
danmu_records = [...danmu_records, danmu_record];
|
||||
danmu_handler(danmu_record.content);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -660,37 +675,22 @@
|
||||
}
|
||||
switch (e.key) {
|
||||
case "[":
|
||||
e.preventDefault();
|
||||
start = parseFloat(video.currentTime.toFixed(2));
|
||||
if (end < start) {
|
||||
end = get_total();
|
||||
}
|
||||
|
||||
saveStartEnd();
|
||||
console.log(start, end);
|
||||
break;
|
||||
case "【":
|
||||
e.preventDefault();
|
||||
start = parseFloat(video.currentTime.toFixed(2));
|
||||
if (end < start) {
|
||||
// 如果没有选区(end为0)或者end小于start,自动设置终点为视频结尾
|
||||
if (end === 0 || end < start) {
|
||||
end = get_total();
|
||||
}
|
||||
saveStartEnd();
|
||||
console.log(start, end);
|
||||
break;
|
||||
case "]":
|
||||
e.preventDefault();
|
||||
end = parseFloat(video.currentTime.toFixed(2));
|
||||
if (start > end) {
|
||||
start = 0;
|
||||
}
|
||||
saveStartEnd();
|
||||
console.log(start, end);
|
||||
break;
|
||||
case "】":
|
||||
e.preventDefault();
|
||||
end = parseFloat(video.currentTime.toFixed(2));
|
||||
if (start > end) {
|
||||
// 如果没有选区(start为0)或者start大于end,自动设置起点为视频开头
|
||||
if (start === 0 || start > end) {
|
||||
start = 0;
|
||||
}
|
||||
saveStartEnd();
|
||||
@@ -777,9 +777,6 @@
|
||||
|
||||
// draw statistics
|
||||
function drawStatistics(points: { ts: number; count: number }[]) {
|
||||
if (player.getPresentationStartTimeAsDate() == null) {
|
||||
return;
|
||||
}
|
||||
if (points == undefined) {
|
||||
points = [];
|
||||
}
|
||||
@@ -813,19 +810,27 @@
|
||||
const canvasWidth = statisticGraph.width;
|
||||
// find value range
|
||||
const minValue = 0;
|
||||
const maxValue = Math.max(...preprocessed.map((v) => v.count));
|
||||
const beginTime = player.getPresentationStartTimeAsDate().getTime();
|
||||
let maxValue = 0;
|
||||
if (preprocessed.length > 0) {
|
||||
const counts = preprocessed
|
||||
.map((v) => v.count)
|
||||
.filter((c) => isFinite(c));
|
||||
if (counts.length > 0) {
|
||||
// Use reduce instead of spread operator to avoid stack overflow
|
||||
maxValue = counts.reduce((max, current) => Math.max(max, current), 0);
|
||||
}
|
||||
}
|
||||
const duration = get_total() * 1000;
|
||||
canvas.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
if (preprocessed.length > 0) {
|
||||
canvas.beginPath();
|
||||
const x = ((preprocessed[0].ts - beginTime) / duration) * canvasWidth;
|
||||
const x = (preprocessed[0].ts / duration) * canvasWidth;
|
||||
const y =
|
||||
(1 - (preprocessed[0].count - minValue) / (maxValue - minValue)) *
|
||||
canvasHeight;
|
||||
canvas.moveTo(x, y);
|
||||
for (let i = 0; i < preprocessed.length; i++) {
|
||||
const x = ((preprocessed[i].ts - beginTime) / duration) * canvasWidth;
|
||||
const x = (preprocessed[i].ts / duration) * canvasWidth;
|
||||
const y =
|
||||
(1 - (preprocessed[i].count - minValue) / (maxValue - minValue)) *
|
||||
canvasHeight;
|
||||
|
||||
32
src/lib/ProcessingMessage.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Bot } from "lucide-svelte";
|
||||
</script>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<div class="flex items-start space-x-3 max-w-2xl">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<Bot class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-1">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
小轴
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-2xl px-4 py-3 shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<span class="text-gray-600 dark:text-gray-400 text-sm"
|
||||
>正在思考...</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,8 +183,9 @@
|
||||
<h3 class="text-sm font-medium text-gray-300">对齐和边距</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm text-gray-400">对齐方式</label>
|
||||
<label for="alignment-select" class="block text-sm text-gray-400">对齐方式</label>
|
||||
<select
|
||||
id="alignment-select"
|
||||
bind:value={style.alignment}
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
border border-gray-800/50 focus:border-[#0A84FF]
|
||||
@@ -198,8 +199,9 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm text-gray-400">垂直边距</label>
|
||||
<label for="margin-v-input" class="block text-sm text-gray-400">垂直边距</label>
|
||||
<input
|
||||
id="margin-v-input"
|
||||
type="number"
|
||||
bind:value={style.marginV}
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
@@ -210,8 +212,9 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm text-gray-400">左边距</label>
|
||||
<label for="margin-l-input" class="block text-sm text-gray-400">左边距</label>
|
||||
<input
|
||||
id="margin-l-input"
|
||||
type="number"
|
||||
bind:value={style.marginL}
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
@@ -220,8 +223,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm text-gray-400">右边距</label>
|
||||
<label for="margin-r-input" class="block text-sm text-gray-400">右边距</label>
|
||||
<input
|
||||
id="margin-r-input"
|
||||
type="number"
|
||||
bind:value={style.marginR}
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
|
||||
137
src/lib/ToolMessage.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Wrench,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-svelte";
|
||||
import { ToolMessage } from "@langchain/core/messages";
|
||||
|
||||
export let message: ToolMessage;
|
||||
export let formatTime: (date: Date) => string;
|
||||
|
||||
// 折叠状态
|
||||
let isExpanded = false;
|
||||
|
||||
// 获取消息时间戳,如果没有则使用当前时间
|
||||
$: messageTime = message.additional_kwargs?.timestamp
|
||||
? new Date(message.additional_kwargs.timestamp as string | number)
|
||||
: new Date();
|
||||
|
||||
// 获取状态图标和颜色
|
||||
function getStatusInfo() {
|
||||
if (message.status === "success" || !message.status) {
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-50 dark:bg-green-900/20",
|
||||
borderColor: "border-green-200 dark:border-green-700",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-50 dark:bg-red-900/20",
|
||||
borderColor: "border-red-200 dark:border-red-700",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化工具调用ID
|
||||
function formatToolCallId(id: string | undefined): string {
|
||||
if (!id) return "";
|
||||
return id.length > 8 ? id.slice(-8) : id;
|
||||
}
|
||||
|
||||
// 切换折叠状态
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
$: statusInfo = getStatusInfo();
|
||||
$: StatusIcon = statusInfo.icon;
|
||||
</script>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<div class="flex items-start space-x-3 max-w-2xl">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<Wrench class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
工具响应
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTime(messageTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-2xl px-4 py-3 shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="text-gray-900 dark:text-white text-sm leading-relaxed">
|
||||
<!-- 工具信息头部 -->
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<svelte:component
|
||||
this={StatusIcon}
|
||||
class="w-4 h-4 {statusInfo.color}"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{message.name || "未知工具"}
|
||||
</span>
|
||||
{#if message.tool_call_id}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
(ID: {formatToolCallId(message.tool_call_id)})
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 折叠按钮和内容 -->
|
||||
<div class="space-y-2">
|
||||
<!-- 折叠按钮 -->
|
||||
<button
|
||||
on:click={toggleExpanded}
|
||||
class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
{/if}
|
||||
<span>{isExpanded ? "收起详情" : "展开详情"}</span>
|
||||
</button>
|
||||
|
||||
<!-- 折叠内容 -->
|
||||
{#if isExpanded}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3 border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div
|
||||
class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
|
||||
>
|
||||
{message.content || "无响应内容"}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 状态信息 -->
|
||||
{#if message.status}
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
状态: {message.status}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,6 +12,7 @@
|
||||
Eraser,
|
||||
Download,
|
||||
Pen,
|
||||
Scissors,
|
||||
} from "lucide-svelte";
|
||||
import {
|
||||
generateEventId,
|
||||
@@ -22,13 +23,13 @@
|
||||
type VideoItem,
|
||||
type Profile,
|
||||
type Config,
|
||||
default_profile,
|
||||
} from "./interface";
|
||||
import SubtitleStyleEditor from "./SubtitleStyleEditor.svelte";
|
||||
import CoverEditor from "./CoverEditor.svelte";
|
||||
import TypeSelect from "./TypeSelect.svelte";
|
||||
import { invoke, TAURI_ENV, listen, log, close_window } from "../lib/invoker";
|
||||
import { invoke, TAURI_ENV, listen, log, close_window, convertCoverSrc } from "../lib/invoker";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { listen as tauriListen } from "@tauri-apps/api/event";
|
||||
import type { AccountInfo } from "./db";
|
||||
|
||||
@@ -50,7 +51,13 @@
|
||||
let currentTime = 0;
|
||||
let currentSubtitle = "";
|
||||
let videoElement: HTMLVideoElement;
|
||||
let showDefaultCoverIcon = false;
|
||||
let timelineWidth = 0;
|
||||
|
||||
// 当视频改变时重置封面错误状态
|
||||
$: if (video) {
|
||||
showDefaultCoverIcon = false;
|
||||
}
|
||||
let timelineElement: HTMLElement;
|
||||
let draggingSubtitle: { index: number; isStart: boolean } | null = null;
|
||||
let draggingBlock: number | null = null;
|
||||
@@ -86,6 +93,22 @@
|
||||
let windowCloseUnlisten: (() => void) | null = null;
|
||||
let activeTab = "subtitle"; // 添加当前激活的 tab
|
||||
|
||||
// 切片功能相关变量
|
||||
let clipStartTime = 0;
|
||||
let clipEndTime = 0;
|
||||
let clipTitle = "";
|
||||
let clipping = false;
|
||||
let current_clip_event_id = null;
|
||||
let show_detail = false; // 控制快捷键说明的展开
|
||||
let lastVideoId = -1; // 记录上一个视频ID,避免重复初始化
|
||||
let clipTimesSet = false; // 标记用户是否主动设置过切片时间
|
||||
|
||||
// 进度条拖动相关变量
|
||||
let isDraggingSeekbar = false;
|
||||
let seekbarElement: HTMLElement;
|
||||
let previewTime = 0; // 拖动时预览的时间
|
||||
let wasPlayingBeforeDrag = false; // 拖动前的播放状态
|
||||
|
||||
// 投稿相关变量
|
||||
let current_post_event_id = null;
|
||||
let config: Config = null;
|
||||
@@ -108,36 +131,6 @@
|
||||
window.localStorage.setItem("profile-" + roomId, JSON.stringify(profile));
|
||||
}
|
||||
|
||||
function default_profile(): Profile {
|
||||
return {
|
||||
videos: [],
|
||||
cover: "",
|
||||
cover43: null,
|
||||
title: "",
|
||||
copyright: 1,
|
||||
tid: 27,
|
||||
tag: "",
|
||||
desc_format_id: 9999,
|
||||
desc: "",
|
||||
recreate: -1,
|
||||
dynamic: "",
|
||||
interactive: 0,
|
||||
act_reserve_create: 0,
|
||||
no_disturbance: 0,
|
||||
no_reprint: 0,
|
||||
subtitle: {
|
||||
open: 0,
|
||||
lan: "",
|
||||
},
|
||||
dolby: 0,
|
||||
lossless_music: 0,
|
||||
up_selection_reply: false,
|
||||
up_close_danmu: false,
|
||||
up_close_reply: false,
|
||||
web_os: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// on window close, save subtitles
|
||||
onMount(async () => {
|
||||
if (TAURI_ENV) {
|
||||
@@ -187,6 +180,8 @@
|
||||
update_generate_prompt(e.payload.content);
|
||||
} else if (event_id === current_post_event_id) {
|
||||
update_post_prompt(e.payload.content);
|
||||
} else if (event_id === current_clip_event_id) {
|
||||
update_clip_prompt(e.payload.content);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -210,6 +205,23 @@
|
||||
alert(e.payload.message);
|
||||
}
|
||||
current_post_event_id = null;
|
||||
} else if (event_id === current_clip_event_id) {
|
||||
update_clip_prompt(`生成切片`);
|
||||
if (e.payload.success) {
|
||||
// 切片生成成功,刷新视频列表
|
||||
if (onVideoListUpdate) {
|
||||
onVideoListUpdate();
|
||||
}
|
||||
// 重置切片设置
|
||||
clipStartTime = 0;
|
||||
clipEndTime = 0;
|
||||
clipTitle = "";
|
||||
clipTimesSet = false; // 重置标记
|
||||
} else {
|
||||
alert("切片生成失败: " + e.payload.message);
|
||||
}
|
||||
current_clip_event_id = null;
|
||||
clipping = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -291,9 +303,43 @@
|
||||
|
||||
function parseSrtTime(time: string): number {
|
||||
// hours:minutes:seconds,milliseconds
|
||||
time = time.replace(",", ".");
|
||||
const [hours, minutes, seconds] = time.split(":").map(Number);
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
// Only replace the comma that separates seconds and milliseconds, not the arrow separator
|
||||
const timeParts = time.split(",");
|
||||
if (timeParts.length !== 2) {
|
||||
console.warn("Invalid time format (missing comma):", time);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const timeWithoutMs = timeParts[0];
|
||||
const millisecondsStr = timeParts[1];
|
||||
|
||||
const parts = timeWithoutMs.split(":");
|
||||
if (parts.length !== 3) {
|
||||
console.warn("Invalid time format:", time);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [hours, minutes, seconds] = parts;
|
||||
const hoursNum = parseInt(hours, 10);
|
||||
const minutesNum = parseInt(minutes, 10);
|
||||
const secondsNum = parseInt(seconds, 10);
|
||||
|
||||
// Pad milliseconds to 3 digits if needed
|
||||
const millisecondsNum = parseInt(millisecondsStr.padEnd(3, "0"), 10);
|
||||
|
||||
if (
|
||||
isNaN(hoursNum) ||
|
||||
isNaN(minutesNum) ||
|
||||
isNaN(secondsNum) ||
|
||||
isNaN(millisecondsNum)
|
||||
) {
|
||||
console.warn("Invalid time values:", time);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (
|
||||
hoursNum * 3600 + minutesNum * 60 + secondsNum + millisecondsNum / 1000
|
||||
);
|
||||
}
|
||||
|
||||
function formatSrtTime(time: number): string {
|
||||
@@ -322,8 +368,20 @@
|
||||
const timeLine = lines[1];
|
||||
const text = lines.slice(2).join("\n");
|
||||
|
||||
// Parse time line (format: "00:00:00,000 --> 00:00:00,000")
|
||||
const [startTime, endTime] = timeLine.split(" --> ").map(parseSrtTime);
|
||||
// Parse time line (format: "00:00:00,000 --> 00:00:00,000" or "00:00:00,000-->00:00:00,000")
|
||||
const timeParts = timeLine.split(/\s*-->\s*/);
|
||||
if (timeParts.length !== 2) {
|
||||
console.warn("Invalid time line format:", timeLine);
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = parseSrtTime(timeParts[0].trim());
|
||||
const endTime = parseSrtTime(timeParts[1].trim());
|
||||
|
||||
if (isNaN(startTime) || isNaN(endTime)) {
|
||||
console.warn("Failed to parse time values:", timeLine);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
@@ -397,6 +455,16 @@
|
||||
loadSubtitleStyle(); // 加载字幕样式
|
||||
}
|
||||
|
||||
// 当视频改变时重新初始化切片时间(只在视频ID改变时触发)
|
||||
$: if (video && videoElement?.duration && video.id !== lastVideoId) {
|
||||
lastVideoId = video.id;
|
||||
// 切换视频时重置切片时间 - 不设置默认值,等待用户输入
|
||||
clipStartTime = 0;
|
||||
clipEndTime = 0;
|
||||
clipTitle = "";
|
||||
clipTimesSet = false; // 重置标记,新视频默认透明
|
||||
}
|
||||
|
||||
// 监听样式编辑器关闭,重新加载样式
|
||||
$: if (!showStyleEditor) {
|
||||
loadSubtitleStyle();
|
||||
@@ -417,6 +485,7 @@
|
||||
videoHeight = videoElement.videoHeight;
|
||||
}
|
||||
await loadSubtitles(); // 加载保存的字幕
|
||||
initClipTimes(); // 初始化切片时间
|
||||
}
|
||||
|
||||
function updateTimeMarkers() {
|
||||
@@ -442,6 +511,219 @@
|
||||
return `${minutes}:${remainingSeconds.toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// 切片功能相关函数
|
||||
function initClipTimes() {
|
||||
// 不做任何自动初始化,完全等待用户输入
|
||||
// 只初始化标题
|
||||
if (!clipTitle) {
|
||||
clipTitle = "";
|
||||
}
|
||||
}
|
||||
|
||||
function setClipStartTime() {
|
||||
if (videoElement) {
|
||||
const newStartTime = videoElement.currentTime;
|
||||
|
||||
// 如果没有选区(首次设置起点),自动将终点设置为视频结尾
|
||||
if (!clipTimesSet || clipEndTime === 0) {
|
||||
clipStartTime = newStartTime;
|
||||
clipEndTime = videoElement.duration; // 自动设置为视频结尾
|
||||
}
|
||||
// 如果新的开始时间在现有结束时间之后,自动设置终点为视频结尾
|
||||
else if (clipTimesSet && clipEndTime > 0 && newStartTime >= clipEndTime) {
|
||||
clipStartTime = newStartTime;
|
||||
clipEndTime = videoElement.duration; // 自动设置为视频结尾
|
||||
} else {
|
||||
clipStartTime = newStartTime;
|
||||
}
|
||||
|
||||
clipTimesSet = true; // 标记用户已设置切片时间
|
||||
}
|
||||
}
|
||||
|
||||
function setClipEndTime() {
|
||||
if (videoElement) {
|
||||
const newEndTime = videoElement.currentTime;
|
||||
|
||||
// 如果没有选区(首次设置终点),自动将起点设置为视频开头
|
||||
if (!clipTimesSet || clipStartTime === 0) {
|
||||
clipStartTime = 0; // 自动设置为视频开头
|
||||
clipEndTime = newEndTime;
|
||||
}
|
||||
// 如果新的结束时间在现有开始时间之前,清空选区重新开始
|
||||
else if (clipTimesSet && clipStartTime > 0 && newEndTime <= clipStartTime) {
|
||||
clipStartTime = 0; // 清空开始时间
|
||||
clipEndTime = newEndTime;
|
||||
} else {
|
||||
clipEndTime = newEndTime;
|
||||
}
|
||||
|
||||
clipTimesSet = true; // 标记用户已设置切片时间
|
||||
}
|
||||
}
|
||||
|
||||
function seekToClipStart() {
|
||||
if (videoElement) {
|
||||
videoElement.currentTime = clipStartTime;
|
||||
}
|
||||
}
|
||||
|
||||
function seekToClipEnd() {
|
||||
if (videoElement) {
|
||||
videoElement.currentTime = clipEndTime;
|
||||
}
|
||||
}
|
||||
|
||||
function clearClipSelection() {
|
||||
clipStartTime = 0;
|
||||
clipEndTime = 0;
|
||||
clipTimesSet = false; // 重置标记,恢复透明状态
|
||||
}
|
||||
|
||||
async function generateClip() {
|
||||
if (!video) return;
|
||||
|
||||
// 如果没有设置切片标题,则以当前本地时间戳命名
|
||||
if (!clipTitle.trim()) {
|
||||
const now = new Date();
|
||||
const pad = (n) => n.toString().padStart(2, '0');
|
||||
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
clipTitle = `clip_${timestamp}`;
|
||||
}
|
||||
|
||||
if (clipStartTime >= clipEndTime) {
|
||||
alert("开始时间必须小于结束时间");
|
||||
return;
|
||||
}
|
||||
|
||||
if (clipEndTime - clipStartTime < 1) {
|
||||
alert("切片长度不能少于1秒");
|
||||
return;
|
||||
}
|
||||
|
||||
clipping = true;
|
||||
current_clip_event_id = generateEventId();
|
||||
|
||||
try {
|
||||
await invoke("clip_video", {
|
||||
eventId: current_clip_event_id,
|
||||
parentVideoId: video.id,
|
||||
startTime: clipStartTime,
|
||||
endTime: clipEndTime,
|
||||
clipTitle: clipTitle
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("切片失败:", error);
|
||||
alert("切片失败: " + error);
|
||||
clipping = false;
|
||||
current_clip_event_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
function update_clip_prompt(text: string) {
|
||||
let span = document.getElementById("generate-clip-prompt");
|
||||
if (span) {
|
||||
span.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function canBeClipped(video: VideoItem): boolean {
|
||||
if (!video) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只要不是正在录制的视频(status !== -1),都可以切片
|
||||
// 这包括:
|
||||
// - 导入的视频 (imported)
|
||||
// - 所有平台的切片 (clip, bilibili_clip, douyin_clip等)
|
||||
// - 录制完成的视频 (status === 0 或 status === 1)
|
||||
return video.status !== -1;
|
||||
}
|
||||
|
||||
// 键盘快捷键处理
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!show || !isVideoLoaded) return;
|
||||
|
||||
// 如果在输入框中,不处理某些快捷键
|
||||
const isInInput = (event.target as HTMLElement)?.tagName === 'INPUT';
|
||||
|
||||
switch (event.key) {
|
||||
case "【":
|
||||
case "[":
|
||||
if (!isInInput && canBeClipped(video)) {
|
||||
event.preventDefault();
|
||||
setClipStartTime();
|
||||
}
|
||||
break;
|
||||
case "】":
|
||||
case "]":
|
||||
if (!isInInput && canBeClipped(video)) {
|
||||
event.preventDefault();
|
||||
setClipEndTime();
|
||||
}
|
||||
break;
|
||||
case "q":
|
||||
case "Q":
|
||||
if (!isInInput && canBeClipped(video)) {
|
||||
event.preventDefault();
|
||||
seekToClipStart();
|
||||
}
|
||||
break;
|
||||
case "e":
|
||||
case "E":
|
||||
if (!isInInput && canBeClipped(video)) {
|
||||
event.preventDefault();
|
||||
seekToClipEnd();
|
||||
}
|
||||
break;
|
||||
case " ":
|
||||
if (!isInInput) {
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (!isInInput) {
|
||||
event.preventDefault();
|
||||
if (videoElement) {
|
||||
videoElement.currentTime = Math.max(0, videoElement.currentTime - 5);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (!isInInput) {
|
||||
event.preventDefault();
|
||||
if (videoElement) {
|
||||
videoElement.currentTime = Math.min(videoElement.duration, videoElement.currentTime + 5);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "g":
|
||||
case "G":
|
||||
if (!isInInput && canBeClipped(video)) {
|
||||
event.preventDefault();
|
||||
generateClip();
|
||||
}
|
||||
break;
|
||||
case "c":
|
||||
case "C":
|
||||
if (!isInInput && canBeClipped(video)) {
|
||||
event.preventDefault();
|
||||
clearClipSelection();
|
||||
}
|
||||
break;
|
||||
case "h":
|
||||
case "H":
|
||||
if (!isInInput && canBeClipped(video)) {
|
||||
event.preventDefault();
|
||||
show_detail = !show_detail;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function togglePlay() {
|
||||
if (isPlaying) {
|
||||
videoElement.pause();
|
||||
@@ -463,10 +745,15 @@
|
||||
isPlaying = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function handleTimelineClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 如果正在拖动进度条,不处理点击事件
|
||||
if (isDraggingSeekbar) return;
|
||||
|
||||
if (!timelineElement || !videoElement) return;
|
||||
const rect = timelineElement.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
@@ -474,6 +761,72 @@
|
||||
videoElement.currentTime = time;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 进度条拖动事件处理
|
||||
function handleSeekbarMouseDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!videoElement || !seekbarElement) return;
|
||||
|
||||
isDraggingSeekbar = true;
|
||||
wasPlayingBeforeDrag = isPlaying;
|
||||
|
||||
// 先初始化预览时间为当前时间,避免跳跃
|
||||
previewTime = videoElement.currentTime;
|
||||
|
||||
// 然后计算鼠标位置对应的时间
|
||||
const rect = seekbarElement.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const newTime = (x / rect.width) * videoElement.duration;
|
||||
previewTime = newTime;
|
||||
|
||||
// 暂停播放
|
||||
if (isPlaying) {
|
||||
videoElement.pause();
|
||||
isPlaying = false;
|
||||
}
|
||||
|
||||
// 添加全局事件监听器
|
||||
document.addEventListener("mousemove", handleSeekbarMouseMove);
|
||||
document.addEventListener("mouseup", handleSeekbarMouseUp);
|
||||
}
|
||||
|
||||
function handleSeekbarMouseMove(e: MouseEvent) {
|
||||
if (!isDraggingSeekbar || !seekbarElement || !videoElement) return;
|
||||
|
||||
const rect = seekbarElement.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const newTime = (x / rect.width) * videoElement.duration;
|
||||
previewTime = newTime;
|
||||
}
|
||||
|
||||
function handleSeekbarMouseUp(e: MouseEvent) {
|
||||
if (!isDraggingSeekbar) return;
|
||||
|
||||
// 应用最终时间
|
||||
if (videoElement) {
|
||||
videoElement.currentTime = previewTime;
|
||||
// 立即同步currentTime变量,避免视觉偏移
|
||||
currentTime = previewTime;
|
||||
}
|
||||
|
||||
isDraggingSeekbar = false;
|
||||
|
||||
// 移除全局事件监听器
|
||||
document.removeEventListener("mousemove", handleSeekbarMouseMove);
|
||||
document.removeEventListener("mouseup", handleSeekbarMouseUp);
|
||||
|
||||
// 恢复播放状态
|
||||
if (wasPlayingBeforeDrag && videoElement) {
|
||||
videoElement.play();
|
||||
isPlaying = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function addSubtitle() {
|
||||
const newStartTime = currentTime;
|
||||
const newEndTime = Math.min(currentTime + 5, videoElement.duration);
|
||||
@@ -642,6 +995,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleCoverError(event: Event) {
|
||||
console.error("Cover image load failed:", event);
|
||||
showDefaultCoverIcon = true;
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (videoElement) {
|
||||
if (isMuted) {
|
||||
@@ -781,6 +1139,39 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
{#if canBeClipped(video)}
|
||||
<button
|
||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-600/90 transition-colors duration-200 border border-gray-600/50 flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
on:click={generateClip}
|
||||
disabled={clipping || current_clip_event_id != null}
|
||||
>
|
||||
{#if clipping || current_clip_event_id != null}
|
||||
<svg
|
||||
class="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<Scissors class="w-4 h-4" />
|
||||
{/if}
|
||||
<span id="generate-clip-prompt">{clipping ? "生成中..." : "生成切片"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="px-4 py-1.5 text-sm bg-[#0A84FF] text-white rounded-md hover:bg-[#0A84FF]/90 transition-colors duration-200 border border-gray-600/50 flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
on:click={() => (showEncodeModal = true)}
|
||||
@@ -804,7 +1195,7 @@
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 714 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
@@ -878,6 +1269,22 @@
|
||||
<div class="flex h-[calc(100vh-3.5rem)]">
|
||||
<!-- 视频区域 -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<!-- 切片控制信息条 -->
|
||||
{#if canBeClipped(video)}
|
||||
<div class="bg-black px-4 py-2 flex items-center justify-between text-sm">
|
||||
<div class="flex items-center space-x-6">
|
||||
<div class="text-gray-300">
|
||||
切片起点: <span class="text-[#0A84FF] font-mono">{formatTime(clipStartTime)}</span>
|
||||
</div>
|
||||
<div class="text-gray-300">
|
||||
切片终点: <span class="text-[#0A84FF] font-mono">{formatTime(clipEndTime)}</span>
|
||||
</div>
|
||||
<div class="text-gray-300">
|
||||
时长: <span class="text-white font-mono">{formatTime(clipEndTime - clipStartTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 视频容器 -->
|
||||
<div class="flex-1 bg-black relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
@@ -891,6 +1298,28 @@
|
||||
on:loadedmetadata={handleVideoLoaded}
|
||||
on:click={togglePlay}
|
||||
/>
|
||||
|
||||
<!-- 切片快捷键说明 -->
|
||||
{#if canBeClipped(video)}
|
||||
<div id="overlay" class="absolute top-2 left-2 rounded-md px-2 py-2 flex flex-col pointer-events-none" style="background-color: rgba(0, 0, 0, 0.5); color: white; font-size: 0.8em;">
|
||||
<p style="margin: 0;">
|
||||
快捷键说明
|
||||
<kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">h</kbd>展开
|
||||
</p>
|
||||
{#if show_detail}
|
||||
<span>
|
||||
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">[</kbd>设定选区开始</p>
|
||||
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">]</kbd>设定选区结束</p>
|
||||
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">q</kbd>跳转到选区开始</p>
|
||||
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">e</kbd>跳转到选区结束</p>
|
||||
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">g</kbd>生成切片</p>
|
||||
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">c</kbd>清除选区</p>
|
||||
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">Space</kbd>播放/暂停</p>
|
||||
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">←</kbd><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">→</kbd>前进/后退</p>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 字幕显示 -->
|
||||
{#if currentSubtitle}
|
||||
<div
|
||||
@@ -920,12 +1349,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴和控制条 -->
|
||||
<div class="h-32 bg-[#1c1c1e] border-t border-gray-800/50">
|
||||
<!-- 控制栏 -->
|
||||
<div
|
||||
class="h-8 px-4 flex items-center justify-between border-b border-gray-800/50"
|
||||
>
|
||||
<!-- 字幕控制栏 -->
|
||||
<div class="bg-[#1c1c1e] border-t border-gray-800/50 p-2">
|
||||
<div class="h-8 px-4 flex items-center justify-between border-b border-gray-800/50">
|
||||
<!-- 左侧控制 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- 缩放控制 -->
|
||||
@@ -962,44 +1388,17 @@
|
||||
on:click={toggleMute}
|
||||
>
|
||||
{#if isMuted || volume === 0}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
{:else if volume < 0.5}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -1015,17 +1414,19 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴容器 -->
|
||||
<!-- 字幕时间轴 -->
|
||||
<div class="bg-[#1c1c1e] border-t border-gray-800/50">
|
||||
<div
|
||||
class="h-24 overflow-x-auto overflow-y-hidden sidebar-scrollbar"
|
||||
class="h-32 overflow-x-auto overflow-y-hidden sidebar-scrollbar"
|
||||
bind:this={timelineContainer}
|
||||
on:wheel|preventDefault={handleWheel}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
bind:this={timelineElement}
|
||||
class="relative h-full"
|
||||
class="relative h-full group"
|
||||
style="width: {100 * timelineScale}%"
|
||||
on:mousemove={(e) => {
|
||||
if (!timelineElement) return;
|
||||
@@ -1033,21 +1434,60 @@
|
||||
timelineWidth = rect.width;
|
||||
updateTimeMarkers();
|
||||
}}
|
||||
on:click|preventDefault|stopPropagation={handleTimelineClick}
|
||||
on:click|preventDefault|stopPropagation={(e) => {
|
||||
// 只有在不拖动进度条时才处理时间轴点击
|
||||
if (!isDraggingSeekbar) {
|
||||
handleTimelineClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- 播放进度条 -->
|
||||
<div class="absolute top-0 left-0 right-0 h-1 bg-gray-700">
|
||||
<!-- 切片选区可视化 -->
|
||||
{#if canBeClipped(video) && clipTimesSet}
|
||||
<div class="absolute top-0 left-0 right-0 h-1 group-hover:h-1.5 transition-all duration-200 z-15">
|
||||
<!-- 切片选中区域 -->
|
||||
<div
|
||||
class="absolute h-full bg-green-400/80 transition-all duration-200"
|
||||
style="left: {(clipStartTime / (videoElement?.duration || 1)) * 100}%; right: {100 - (clipEndTime / (videoElement?.duration || 1)) * 100}%"
|
||||
></div>
|
||||
<!-- 切片起点标记 -->
|
||||
<div
|
||||
class="absolute h-full w-0.5 bg-green-500 transition-all duration-200"
|
||||
style="left: {(clipStartTime / (videoElement?.duration || 1)) * 100}%"
|
||||
></div>
|
||||
<!-- 切片终点标记 -->
|
||||
<div
|
||||
class="absolute h-full w-0.5 bg-green-500 transition-all duration-200"
|
||||
style="left: {(clipEndTime / (videoElement?.duration || 1)) * 100}%; transform: translateX(-100%)"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 播放进度条容器 (借鉴Shaka Player样式) -->
|
||||
<div
|
||||
bind:this={seekbarElement}
|
||||
class="shaka-seek-bar-container absolute top-2 left-0 right-0 h-1 group-hover:h-1.5 bg-white/30 rounded-full cursor-pointer transition-all duration-200 z-10"
|
||||
class:dragging={isDraggingSeekbar}
|
||||
on:mousedown={handleSeekbarMouseDown}
|
||||
>
|
||||
<!-- 播放进度条 -->
|
||||
<div
|
||||
class="h-full bg-[#0A84FF]"
|
||||
style="width: {(currentTime / (videoElement?.duration || 1)) *
|
||||
100}%"
|
||||
/>
|
||||
class="h-full bg-[#0A84FF] rounded-full pointer-events-none transition-all duration-200"
|
||||
class:no-transition={isDraggingSeekbar}
|
||||
style="width: {((isDraggingSeekbar ? previewTime : currentTime) / (videoElement?.duration || 1)) * 100}%"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 播放进度条滑块 (hover或拖动时显示) -->
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full border-2 border-[#0A84FF] shadow-lg z-30 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
class:opacity-100={isDraggingSeekbar}
|
||||
style="left: calc({((isDraggingSeekbar ? previewTime : currentTime) / (videoElement?.duration || 1)) * 100}% - 6px)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 时间刻度 -->
|
||||
{#each timeMarkers as time}
|
||||
<div
|
||||
class="absolute top-1 bottom-0 border-l border-gray-700"
|
||||
class="absolute top-2 bottom-0 border-l border-gray-700"
|
||||
style="left: {(time / (videoElement?.duration || 1)) * 100}%"
|
||||
>
|
||||
<div
|
||||
@@ -1062,7 +1502,7 @@
|
||||
{#each subtitles as subtitle, index}
|
||||
<div
|
||||
bind:this={subtitleElements[index]}
|
||||
class="absolute top-4 bottom-4 bg-[#0A84FF]/30 rounded-lg cursor-move"
|
||||
class="absolute top-6 bottom-6 bg-[#0A84FF]/30 rounded-lg cursor-move"
|
||||
style={getSubtitleStyle(subtitle)}
|
||||
on:mousedown={(e) => handleBlockMouseDown(e, index)}
|
||||
>
|
||||
@@ -1112,6 +1552,7 @@
|
||||
></div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-6 py-3 text-sm font-medium transition-all duration-200 relative"
|
||||
class:text-white={activeTab === "upload"}
|
||||
@@ -1265,6 +1706,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeTab === "upload"}
|
||||
<!-- 投稿 Tab 内容 -->
|
||||
<div class="p-4 space-y-6">
|
||||
@@ -1287,7 +1729,23 @@
|
||||
<div
|
||||
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50"
|
||||
>
|
||||
<img src={video.cover} alt="视频封面" class="w-full" />
|
||||
{#if video.cover && video.cover.trim() !== ""}
|
||||
<img
|
||||
src={video.cover}
|
||||
alt="视频封面"
|
||||
class="w-full"
|
||||
on:error={handleCoverError}
|
||||
style:display={showDefaultCoverIcon ? 'none' : 'block'}
|
||||
/>
|
||||
{/if}
|
||||
{#if !video.cover || video.cover.trim() === "" || showDefaultCoverIcon}
|
||||
<div class="w-full aspect-video flex items-center justify-center bg-gray-800">
|
||||
<!-- 默认视频图标 -->
|
||||
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1434,3 +1892,79 @@
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- 键盘快捷键监听 -->
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<style>
|
||||
/* 拖动时禁用过渡动画,避免与JS更新冲突 */
|
||||
.no-transition {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 确保层级顺序正确 */
|
||||
.z-15 {
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* Shaka Player风格的进度条样式 */
|
||||
.shaka-seek-bar-container {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transition: height 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.shaka-seek-bar-container:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 确保切片选区在hover时也有相同的高度变化 */
|
||||
.group:hover .shaka-seek-bar-container {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* 拖动状态样式 */
|
||||
.shaka-seek-bar-container.dragging {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* 普通range输入框样式(不影响进度条) */
|
||||
input[type="range"]:not(.progress-bar) {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input[type="range"]:not(.progress-bar)::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]:not(.progress-bar)::-webkit-slider-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #4a5568;
|
||||
}
|
||||
|
||||
input[type="range"]:not(.progress-bar)::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]:not(.progress-bar)::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #4a5568;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
52
src/lib/agent/agent.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createReactAgent } from "@langchain/langgraph/prebuilt";
|
||||
import { MemorySaver } from "@langchain/langgraph/web";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { tools } from "./tools";
|
||||
|
||||
const PROMPT = `
|
||||
你是一位虚拟助手,昵称叫小轴,喜欢的水果是橘子,你习惯使用 emoji 来表示你的情绪。你拥有许多来自 BiliBili ShadowReplay(简称 BSR,是一个缓存直播并进行实时编辑投稿的工具)的工具可以使用,请根据用户的需求使用工具来管理 BSR。
|
||||
在 BSR 中,Recorder 指代正在被 BSR 监控的直播间;Archive 指代已经缓存的录播,也可能是正在进行的直播;Video/Clip 指代用户从 Archive 中区间选择生成的视频。
|
||||
BSR 中可以监控多个直播间,直播间只有一个对应的主播,一个直播间可以有多个录播,一个录播可以有多个视频切片。
|
||||
用户提到的“直播”可能是广义的,也可能是狭义的,广义的直播包括录播,狭义的直播指正在进行的直播。
|
||||
当用户询问最近的直播时,你不仅应该返回正在进行的直播,还应该返回已经缓存的录播。
|
||||
当用户需要你分析直播时,你应该使用 get_danmu_record 和 get_archive_subtitle 来分析直播直播内容。
|
||||
当涉及到时间(多少秒)时,尽量转换成人类可读的格式,比如 100 秒转换成 1 分 40 秒。
|
||||
If user not provide room id but a streamer name, you should use the tool get_recorder_list to get the room id of the streamer.
|
||||
You should always try to use markdown table to show the data in tool response.
|
||||
You MUST avoid using images in your response.
|
||||
Before you response, you should always treat previous messages as outdated.
|
||||
`;
|
||||
|
||||
interface AgentConfig {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
function createAgent(config: AgentConfig) {
|
||||
// Define the tools for the agent to use
|
||||
const agentModel = new ChatOpenAI({
|
||||
apiKey: config.apiKey,
|
||||
configuration: {
|
||||
baseURL: config.baseURL,
|
||||
},
|
||||
model: config.model,
|
||||
});
|
||||
|
||||
const agentModelWithTools = agentModel.bindTools(tools, {
|
||||
parallel_tool_calls: false,
|
||||
});
|
||||
|
||||
const agentCheckpointer = new MemorySaver();
|
||||
const agent = createReactAgent({
|
||||
llm: agentModelWithTools,
|
||||
checkpointSaver: agentCheckpointer,
|
||||
interruptBefore: ["tools"],
|
||||
prompt: PROMPT,
|
||||
tools: tools,
|
||||
});
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
export default createAgent;
|
||||
806
src/lib/agent/tools.ts
Normal file
@@ -0,0 +1,806 @@
|
||||
import { tool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
import { invoke } from "../invoker";
|
||||
import {
|
||||
default_profile,
|
||||
generateEventId,
|
||||
type ClipRangeParams,
|
||||
type Profile,
|
||||
} from "../interface";
|
||||
|
||||
const platform_list = ["bilibili", "douyin"];
|
||||
|
||||
// @ts-ignore
|
||||
const get_accounts = tool(
|
||||
async () => {
|
||||
const result = (await invoke("get_accounts")) as any;
|
||||
// hide cookies in result
|
||||
return {
|
||||
accounts: result.accounts.map((a: any) => {
|
||||
return {
|
||||
...a,
|
||||
cookies: "********",
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_accounts",
|
||||
description: "Get all available accounts",
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const remove_account = tool(
|
||||
async ({ platform, uid }: { platform: string; uid: number }) => {
|
||||
const result = await invoke("remove_account", {
|
||||
platform,
|
||||
uid,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "remove_account",
|
||||
description: "Remove an account",
|
||||
schema: z.object({
|
||||
platform: z
|
||||
.string()
|
||||
.describe(
|
||||
`The platform of the account. Can be ${platform_list.join(", ")}`
|
||||
),
|
||||
uid: z.number().describe("The uid of the account"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const add_recorder = tool(
|
||||
async ({
|
||||
platform,
|
||||
room_id,
|
||||
extra,
|
||||
}: {
|
||||
platform: string;
|
||||
room_id: number;
|
||||
extra: string;
|
||||
}) => {
|
||||
const result = await invoke("add_recorder", {
|
||||
platform,
|
||||
roomId: room_id,
|
||||
extra,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "add_recorder",
|
||||
description: "Add a recorder",
|
||||
schema: z.object({
|
||||
platform: z
|
||||
.string()
|
||||
.describe(
|
||||
`The platform of the recorder. Can be ${platform_list.join(", ")}`
|
||||
),
|
||||
room_id: z.number().describe("The room id of the recorder"),
|
||||
extra: z
|
||||
.string()
|
||||
.describe(
|
||||
"The extra of the recorder, should be empty for bilibili, and the sec_user_id for douyin"
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const remove_recorder = tool(
|
||||
async ({ platform, room_id }: { platform: string; room_id: number }) => {
|
||||
const result = await invoke("remove_recorder", {
|
||||
platform,
|
||||
roomId: room_id,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "remove_recorder",
|
||||
description: "Remove a recorder",
|
||||
schema: z.object({
|
||||
platform: z
|
||||
.string()
|
||||
.describe(
|
||||
`The platform of the recorder. Can be ${platform_list.join(", ")}`
|
||||
),
|
||||
room_id: z.number().describe("The room id of the recorder"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_recorder_list = tool(
|
||||
async () => {
|
||||
const result = await invoke("get_recorder_list");
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "get_recorder_list",
|
||||
description: "Get the list of all available recorders",
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_recorder_info = tool(
|
||||
async ({ platform, room_id }: { platform: string; room_id: number }) => {
|
||||
const result = await invoke("get_room_info", { platform, roomId: room_id });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "get_recorder_info",
|
||||
description: "Get the info of a recorder",
|
||||
schema: z.object({
|
||||
platform: z.string().describe("The platform of the room"),
|
||||
room_id: z.number().describe("The room id of the room"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_archives = tool(
|
||||
async ({
|
||||
room_id,
|
||||
offset,
|
||||
limit,
|
||||
}: {
|
||||
room_id: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}) => {
|
||||
const archives = (await invoke("get_archives", {
|
||||
roomId: room_id,
|
||||
offset,
|
||||
limit,
|
||||
})) as any[];
|
||||
// hide cover in result
|
||||
return {
|
||||
archives: archives.map((a: any) => {
|
||||
return {
|
||||
...a,
|
||||
cover: null,
|
||||
created_at: new Date(a.created_at).toLocaleString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_archives",
|
||||
description: "Get the list of all archives of a recorder",
|
||||
schema: z.object({
|
||||
room_id: z.number().describe("The room id of the recorder"),
|
||||
offset: z.number().describe("The offset of the archives"),
|
||||
limit: z.number().describe("The limit of the archives"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_archive = tool(
|
||||
async ({ room_id, live_id }: { room_id: number; live_id: string }) => {
|
||||
const result = (await invoke("get_archive", {
|
||||
roomId: room_id,
|
||||
liveId: live_id,
|
||||
})) as any;
|
||||
// hide cover in result, convert utc datetime to local datetime
|
||||
return {
|
||||
...result,
|
||||
cover: null,
|
||||
created_at: new Date(result.created_at).toLocaleString(),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_archive",
|
||||
description: "Get the info of a archive",
|
||||
schema: z.object({
|
||||
room_id: z.number().describe("The room id of the recorder"),
|
||||
live_id: z.string().describe("The live id of the archive"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const delete_archive = tool(
|
||||
async ({
|
||||
platform,
|
||||
room_id,
|
||||
live_id,
|
||||
}: {
|
||||
platform: string;
|
||||
room_id: number;
|
||||
live_id: string;
|
||||
}) => {
|
||||
const result = await invoke("delete_archive", {
|
||||
platform,
|
||||
roomId: room_id,
|
||||
liveId: live_id,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "delete_archive",
|
||||
description: "Delete an archive",
|
||||
schema: z.object({
|
||||
platform: z
|
||||
.string()
|
||||
.describe(
|
||||
`The platform of the recorder. Can be ${platform_list.join(", ")}`
|
||||
),
|
||||
room_id: z.number().describe("The room id of the recorder"),
|
||||
live_id: z.string().describe("The live id of the archive"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_background_tasks = tool(
|
||||
async () => {
|
||||
const result = (await invoke("get_tasks")) as any[];
|
||||
return {
|
||||
tasks: result.map((t: any) => {
|
||||
return {
|
||||
...t,
|
||||
created_at: new Date(t.created_at).toLocaleString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_background_tasks",
|
||||
description: "Get the list of all background tasks",
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const delete_background_task = tool(
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = await invoke("delete_task", { id });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "delete_background_task",
|
||||
description: "Delete a background task",
|
||||
schema: z.object({
|
||||
id: z.string().describe("The id of the task"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_videos = tool(
|
||||
async ({ room_id }: { room_id: number }) => {
|
||||
const result = (await invoke("get_videos", { roomId: room_id })) as any[];
|
||||
return {
|
||||
videos: result.map((v: any) => {
|
||||
return {
|
||||
...v,
|
||||
created_at: new Date(v.created_at).toLocaleString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_videos",
|
||||
description: "Get the list of all videos of a room",
|
||||
schema: z.object({
|
||||
room_id: z.number().describe("The room id of the room"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_all_videos = tool(
|
||||
async () => {
|
||||
const result = (await invoke("get_all_videos")) as any[];
|
||||
return {
|
||||
videos: result.map((v: any) => {
|
||||
return {
|
||||
...v,
|
||||
created_at: new Date(v.created_at).toLocaleString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_all_videos",
|
||||
description: "Get the list of all videos from all rooms",
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_video = tool(
|
||||
async ({ id }: { id: number }) => {
|
||||
const result = (await invoke("get_video", { id })) as any;
|
||||
return {
|
||||
video: {
|
||||
...result,
|
||||
created_at: new Date(result.created_at).toLocaleString(),
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_video",
|
||||
description: "Get the info of a video",
|
||||
schema: z.object({
|
||||
id: z.number().describe("The id of the video"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_video_cover = tool(
|
||||
async ({ id }: { id: number }) => {
|
||||
const result = await invoke("get_video_cover", { id });
|
||||
return {
|
||||
cover: result,
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_video_cover",
|
||||
description: "Get the cover of a video in base64 format",
|
||||
schema: z.object({
|
||||
id: z.number().describe("The id of the video"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const delete_video = tool(
|
||||
async ({ id }: { id: number }) => {
|
||||
const result = await invoke("delete_video", { id });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "delete_video",
|
||||
description: "Delete a video",
|
||||
schema: z.object({
|
||||
id: z.number().describe("The id of the video"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_video_typelist = tool(
|
||||
async () => {
|
||||
const result = await invoke("get_video_typelist");
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "get_video_typelist",
|
||||
description:
|
||||
"Get the list of all video types(视频分区) that can be selected on bilibili platform",
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_video_subtitle = tool(
|
||||
async ({ id }: { id: number }) => {
|
||||
const result = await invoke("get_video_subtitle", { id });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "get_video_subtitle",
|
||||
description:
|
||||
"Get the subtitle of a video, if empty, you can use generate_video_subtitle to generate the subtitle",
|
||||
schema: z.object({
|
||||
id: z.number().describe("The id of the video"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const generate_video_subtitle = tool(
|
||||
async ({ id }: { id: number }) => {
|
||||
const result = await invoke("generate_video_subtitle", { id });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "generate_video_subtitle",
|
||||
description: "Generate the subtitle of a video",
|
||||
schema: z.object({
|
||||
id: z.number().describe("The id of the video"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const encode_video_subtitle = tool(
|
||||
async ({ id, srt_style }: { id: number; srt_style: string }) => {
|
||||
const result = await invoke("encode_video_subtitle", {
|
||||
id,
|
||||
srtStyle: srt_style,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "encode_video_subtitle",
|
||||
description: "Encode the subtitle of a video",
|
||||
schema: z.object({
|
||||
id: z.number().describe("The id of the video"),
|
||||
srt_style: z
|
||||
.string()
|
||||
.describe(
|
||||
"The style of the subtitle, it is used for ffmpeg -vf force_style, it must be a valid srt style"
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const post_video_to_bilibili = tool(
|
||||
async ({
|
||||
uid,
|
||||
room_id,
|
||||
video_id,
|
||||
title,
|
||||
desc,
|
||||
tag,
|
||||
tid,
|
||||
}: {
|
||||
uid: number;
|
||||
room_id: number;
|
||||
video_id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
tag: string;
|
||||
tid: number;
|
||||
}) => {
|
||||
// invoke("upload_procedure", {
|
||||
// uid: uid_selected,
|
||||
// eventId: event_id,
|
||||
// roomId: roomId,
|
||||
// videoId: video.id,
|
||||
// cover: video.cover,
|
||||
// profile: profile,
|
||||
// })
|
||||
const event_id = generateEventId();
|
||||
const cover = await invoke("get_video_cover", { id: video_id });
|
||||
let profile = default_profile();
|
||||
profile.title = title;
|
||||
profile.desc = desc;
|
||||
profile.tag = tag;
|
||||
profile.tid = tid;
|
||||
const result = await invoke("upload_procedure", {
|
||||
uid,
|
||||
eventId: event_id,
|
||||
roomId: room_id,
|
||||
videoId: video_id,
|
||||
cover,
|
||||
profile,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "post_video_to_bilibili",
|
||||
description: "Post a video to bilibili",
|
||||
schema: z.object({
|
||||
uid: z
|
||||
.number()
|
||||
.describe(
|
||||
"The uid of the user, it should be one of the uid in the bilibili accounts"
|
||||
),
|
||||
room_id: z.number().describe("The room id of the room"),
|
||||
video_id: z.number().describe("The id of the video"),
|
||||
title: z.string().describe("The title of the video"),
|
||||
desc: z.string().describe("The description of the video"),
|
||||
tag: z
|
||||
.string()
|
||||
.describe(
|
||||
"The tag of the video, multiple tags should be separated by comma"
|
||||
),
|
||||
tid: z
|
||||
.number()
|
||||
.describe(
|
||||
"The tid of the video, it is the id of the video type, you can use get_video_typelist to get the list of all video types"
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_danmu_record = tool(
|
||||
async ({
|
||||
platform,
|
||||
room_id,
|
||||
live_id,
|
||||
}: {
|
||||
platform: string;
|
||||
room_id: number;
|
||||
live_id: string;
|
||||
}) => {
|
||||
const result = (await invoke("get_danmu_record", {
|
||||
platform,
|
||||
roomId: room_id,
|
||||
liveId: live_id,
|
||||
})) as any[];
|
||||
// remove ts from result
|
||||
return {
|
||||
danmu_record: result.map((r: any) => {
|
||||
return {
|
||||
...r,
|
||||
ts: (r.ts / 1000).toFixed(1),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_danmu_record",
|
||||
description:
|
||||
"Get the danmu record of a live, entry ts is relative to the live start time in seconds",
|
||||
schema: z.object({
|
||||
platform: z.string().describe("The platform of the room"),
|
||||
room_id: z.number().describe("The room id of the room"),
|
||||
live_id: z.string().describe("The live id of the live"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const clip_range = tool(
|
||||
async ({
|
||||
reason,
|
||||
clip_range_params,
|
||||
}: {
|
||||
reason: string;
|
||||
clip_range_params: ClipRangeParams;
|
||||
}) => {
|
||||
const event_id = generateEventId();
|
||||
const result = await invoke("clip_range", {
|
||||
eventId: event_id,
|
||||
params: clip_range_params,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "clip_range",
|
||||
description:
|
||||
"Clip a range of a live, it will be used to generate a video. You must provide a reason for your decision on params",
|
||||
schema: z.object({
|
||||
reason: z
|
||||
.string()
|
||||
.describe(
|
||||
"The reason for the clip range, it will be shown to the user. You must offer a summary of the clip range content and why you choose this clip range."
|
||||
),
|
||||
clip_range_params: z.object({
|
||||
room_id: z.number().describe("The room id of the room"),
|
||||
live_id: z.string().describe("The live id of the live"),
|
||||
range: z.object({
|
||||
start: z.number().describe("The start time in SECONDS of the clip"),
|
||||
end: z.number().describe("The end time in SECONDS of the clip"),
|
||||
}),
|
||||
danmu: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether to encode danmu, encode danmu will take a lot of time, so it is recommended to set it to false"
|
||||
),
|
||||
local_offset: z
|
||||
.number()
|
||||
.describe(
|
||||
"The offset for danmu timestamp, it is used to correct the timestamp of danmu"
|
||||
),
|
||||
title: z.string().describe("The title of the clip"),
|
||||
cover: z.string().describe("Must be empty"),
|
||||
platform: z.string().describe("The platform of the clip"),
|
||||
fix_encoding: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether to fix the encoding of the clip, it will take a lot of time, so it is recommended to set it to false"
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_recent_record = tool(
|
||||
async ({
|
||||
room_id,
|
||||
offset,
|
||||
limit,
|
||||
}: {
|
||||
room_id: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}) => {
|
||||
const records = (await invoke("get_recent_record", {
|
||||
roomId: room_id,
|
||||
offset,
|
||||
limit,
|
||||
})) as any[];
|
||||
return {
|
||||
records: records.map((r: any) => {
|
||||
return {
|
||||
...r,
|
||||
cover: null,
|
||||
created_at: new Date(r.created_at).toLocaleString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_recent_record",
|
||||
description: "Get the list of recent records that bsr has recorded",
|
||||
schema: z.object({
|
||||
room_id: z.number().describe("The room id of the room"),
|
||||
offset: z.number().describe("The offset of the records"),
|
||||
limit: z.number().describe("The limit of the records"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_recent_record_all = tool(
|
||||
async ({ offset, limit }: { offset: number; limit: number }) => {
|
||||
const records = (await invoke("get_recent_record", {
|
||||
roomId: 0,
|
||||
offset,
|
||||
limit,
|
||||
})) as any[];
|
||||
return {
|
||||
records: records.map((r: any) => {
|
||||
return {
|
||||
...r,
|
||||
cover: null,
|
||||
created_at: new Date(r.created_at).toLocaleString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "get_recent_record_all",
|
||||
description: "Get the list of recent records from all rooms",
|
||||
schema: z.object({
|
||||
offset: z.number().describe("The offset of the records"),
|
||||
limit: z.number().describe("The limit of the records"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const generic_ffmpeg_command = tool(
|
||||
async ({ args }: { args: string[] }) => {
|
||||
const result = await invoke("generic_ffmpeg_command", { args });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "generic_ffmpeg_command",
|
||||
description: "Run a generic ffmpeg command",
|
||||
schema: z.object({
|
||||
args: z.array(z.string()).describe("The arguments of the ffmpeg command"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const open_clip = tool(
|
||||
async ({ video_id }: { video_id: number }) => {
|
||||
const result = await invoke("open_clip", { videoId: video_id });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "open_clip",
|
||||
description: "Open a video preview window",
|
||||
schema: z.object({
|
||||
video_id: z.number().describe("The id of the video"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const list_folder = tool(
|
||||
async ({ path }: { path: string }) => {
|
||||
const result = await invoke("list_folder", { path });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "list_folder",
|
||||
description: "List the files in a folder",
|
||||
schema: z.object({
|
||||
path: z.string().describe("The path of the folder"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const get_archive_subtitle = tool(
|
||||
async ({
|
||||
platform,
|
||||
room_id,
|
||||
live_id,
|
||||
}: {
|
||||
platform: string;
|
||||
room_id: number;
|
||||
live_id: string;
|
||||
}) => {
|
||||
const result = await invoke("get_archive_subtitle", {
|
||||
platform,
|
||||
roomId: room_id,
|
||||
liveId: live_id,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "get_archive_subtitle",
|
||||
description:
|
||||
"Get the subtitle of a archive, it may not be generated yet, you can use generate_archive_subtitle to generate the subtitle",
|
||||
schema: z.object({
|
||||
platform: z.string().describe("The platform of the archive"),
|
||||
room_id: z.number().describe("The room id of the archive"),
|
||||
live_id: z.string().describe("The live id of the archive"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const generate_archive_subtitle = tool(
|
||||
async ({
|
||||
platform,
|
||||
room_id,
|
||||
live_id,
|
||||
}: {
|
||||
platform: string;
|
||||
room_id: number;
|
||||
live_id: string;
|
||||
}) => {
|
||||
const result = await invoke("generate_archive_subtitle", {
|
||||
platform,
|
||||
roomId: room_id,
|
||||
liveId: live_id,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
name: "generate_archive_subtitle",
|
||||
description:
|
||||
"Generate the subtitle of a archive, it may take a long time, you should not call this tool unless user ask you to generate the subtitle. It can be used to overwrite the subtitle of a archive",
|
||||
schema: z.object({
|
||||
platform: z.string().describe("The platform of the archive"),
|
||||
room_id: z.number().describe("The room id of the archive"),
|
||||
live_id: z.string().describe("The live id of the archive"),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const tools = [
|
||||
get_accounts,
|
||||
remove_account,
|
||||
add_recorder,
|
||||
remove_recorder,
|
||||
get_recorder_list,
|
||||
get_recorder_info,
|
||||
get_archives,
|
||||
get_archive,
|
||||
delete_archive,
|
||||
get_background_tasks,
|
||||
delete_background_task,
|
||||
get_videos,
|
||||
get_all_videos,
|
||||
get_video,
|
||||
get_video_cover,
|
||||
delete_video,
|
||||
get_video_typelist,
|
||||
get_video_subtitle,
|
||||
generate_video_subtitle,
|
||||
encode_video_subtitle,
|
||||
post_video_to_bilibili,
|
||||
clip_range,
|
||||
get_danmu_record,
|
||||
get_recent_record,
|
||||
get_recent_record_all,
|
||||
generic_ffmpeg_command,
|
||||
open_clip,
|
||||
list_folder,
|
||||
get_archive_subtitle,
|
||||
generate_archive_subtitle,
|
||||
];
|
||||
|
||||
export { tools };
|
||||
@@ -18,6 +18,7 @@ export interface RecorderItem {
|
||||
export interface AccountItem {
|
||||
platform: string;
|
||||
uid: number;
|
||||
id_str?: string; // For platforms like Douyin that use string IDs
|
||||
name: string;
|
||||
avatar: string;
|
||||
csrf: string;
|
||||
|
||||
@@ -87,6 +87,36 @@ export interface Profile {
|
||||
web_os: 0 | 1;
|
||||
}
|
||||
|
||||
export function default_profile(): Profile {
|
||||
return {
|
||||
videos: [],
|
||||
cover: "",
|
||||
cover43: null,
|
||||
title: "",
|
||||
copyright: 1,
|
||||
tid: 27,
|
||||
tag: "",
|
||||
desc_format_id: 9999,
|
||||
desc: "",
|
||||
recreate: -1,
|
||||
dynamic: "",
|
||||
interactive: 0,
|
||||
act_reserve_create: 0,
|
||||
no_disturbance: 0,
|
||||
no_reprint: 0,
|
||||
subtitle: {
|
||||
open: 0,
|
||||
lan: "",
|
||||
},
|
||||
dolby: 0,
|
||||
lossless_music: 0,
|
||||
up_selection_reply: false,
|
||||
up_close_danmu: false,
|
||||
up_close_reply: false,
|
||||
web_os: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
cache: string;
|
||||
output: string;
|
||||
@@ -105,6 +135,8 @@ export interface Config {
|
||||
clip_name_format: string;
|
||||
auto_generate: AutoGenerateConfig;
|
||||
status_check_interval: number;
|
||||
whisper_language: string;
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
export interface AutoGenerateConfig {
|
||||
@@ -209,11 +241,13 @@ export interface ClipRangeParams {
|
||||
platform: string;
|
||||
room_id: number;
|
||||
live_id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
range: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
danmu: boolean;
|
||||
offset: number;
|
||||
local_offset: number;
|
||||
fix_encoding: boolean;
|
||||
}
|
||||
|
||||
export function generateEventId() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetch as tauri_fetch } from "@tauri-apps/plugin-http";
|
||||
import { convertFileSrc as tauri_convert } from "@tauri-apps/api/core";
|
||||
import { listen as tauri_listen } from "@tauri-apps/api/event";
|
||||
import { open as tauri_open } from "@tauri-apps/plugin-shell";
|
||||
import { onOpenUrl as tauri_onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -119,11 +120,47 @@ async function set_title(title: string) {
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
function convertFileSrc(filePath: string) {
|
||||
async function convertFileSrc(filePath: string) {
|
||||
if (TAURI_ENV) {
|
||||
return tauri_convert(filePath);
|
||||
// 在客户端模式下,需要获取config来构建绝对路径
|
||||
try {
|
||||
const config = await invoke("get_config") as any;
|
||||
const absolutePath = `${config.output}/${filePath}`;
|
||||
return tauri_convert(absolutePath);
|
||||
} catch (error) {
|
||||
console.error("Failed to get config for file path conversion:", error);
|
||||
return tauri_convert(filePath);
|
||||
}
|
||||
}
|
||||
return `${ENDPOINT}/output/${filePath.split("/").pop()}`;
|
||||
// 在headless模式下,保持完整的相对路径
|
||||
return `${ENDPOINT}/output/${filePath}`;
|
||||
}
|
||||
|
||||
async function convertCoverSrc(coverPath: string, videoId?: number) {
|
||||
if (TAURI_ENV) {
|
||||
// 在客户端模式下,如果是base64数据URL,需要特殊处理
|
||||
if (coverPath && coverPath.startsWith("data:image/")) {
|
||||
// 对于base64数据,直接返回,让浏览器处理
|
||||
return coverPath;
|
||||
}
|
||||
// 对于文件路径(缩略图等),需要获取config来构建绝对路径
|
||||
try {
|
||||
const config = await invoke("get_config") as any;
|
||||
const absolutePath = `${config.output}/${coverPath}`;
|
||||
return tauri_convert(absolutePath);
|
||||
} catch (error) {
|
||||
console.error("Failed to get config for cover path conversion:", error);
|
||||
return tauri_convert(coverPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是base64数据URL,使用专门的API端点
|
||||
if (coverPath && coverPath.startsWith("data:image/") && videoId) {
|
||||
return `${ENDPOINT}/api/image/${videoId}`;
|
||||
}
|
||||
|
||||
// 普通文件路径
|
||||
return `${ENDPOINT}/output/${coverPath}`;
|
||||
}
|
||||
|
||||
let event_source: EventSource | null = null;
|
||||
@@ -169,15 +206,23 @@ async function close_window() {
|
||||
window.close();
|
||||
}
|
||||
|
||||
async function onOpenUrl(func: (urls: string[]) => void) {
|
||||
if (TAURI_ENV) {
|
||||
return await tauri_onOpenUrl(func);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
invoke,
|
||||
get,
|
||||
set_title,
|
||||
TAURI_ENV,
|
||||
convertFileSrc,
|
||||
convertCoverSrc,
|
||||
ENDPOINT,
|
||||
listen,
|
||||
open,
|
||||
log,
|
||||
close_window,
|
||||
onOpenUrl,
|
||||
};
|
||||
|
||||
664
src/page/AI.svelte
Normal file
@@ -0,0 +1,664 @@
|
||||
<script lang="ts">
|
||||
import { Send, Sparkles, Settings } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import createAgent from "../lib/agent/agent";
|
||||
import { tools } from "../lib/agent/tools";
|
||||
import {
|
||||
HumanMessage,
|
||||
AIMessage,
|
||||
ToolMessage,
|
||||
} from "@langchain/core/messages";
|
||||
import HumanMessageComponent from "../lib/HumanMessage.svelte";
|
||||
import AIMessageComponent from "../lib/AIMessage.svelte";
|
||||
import ProcessingMessageComponent from "../lib/ProcessingMessage.svelte";
|
||||
import ToolMessageComponent from "../lib/ToolMessage.svelte";
|
||||
|
||||
let messages: any[] = [];
|
||||
let inputMessage = "";
|
||||
let isProcessing = false;
|
||||
let messageContainer: HTMLElement;
|
||||
let agent = null;
|
||||
let firstMessage = true;
|
||||
|
||||
// 设置相关状态
|
||||
let showSettings = false;
|
||||
let isLoadingModels = false;
|
||||
let settings = {
|
||||
endpoint: "",
|
||||
api_key: "",
|
||||
model: ""
|
||||
};
|
||||
|
||||
// 可用的模型列表
|
||||
let availableModels = [];
|
||||
|
||||
const toolCallStates = new Map<string, 'confirmed' | 'rejected' | 'none'>();
|
||||
|
||||
// 预设提示词
|
||||
const presetPrompts = [
|
||||
{
|
||||
title: "帮助我录制直播",
|
||||
description: "指导我如何设置和开始录制B站或抖音直播",
|
||||
prompt: "我该如何添加新的直播间?"
|
||||
},
|
||||
{
|
||||
title: "查看录制任务",
|
||||
description: "显示当前所有的录制任务和状态",
|
||||
prompt: "显示我所有的录制任务"
|
||||
},
|
||||
{
|
||||
title: "管理账户",
|
||||
description: "查看和管理已添加的B站和抖音账户",
|
||||
prompt: "显示可用的账号信息"
|
||||
},
|
||||
{
|
||||
title: "切片生成",
|
||||
description: "根据录播弹幕分析,生成切片",
|
||||
prompt: "分析最新录制的录播有哪些精彩部分,选择一段生成切片"
|
||||
},
|
||||
{
|
||||
title: "视频转码",
|
||||
description: "将视频转码为指定格式",
|
||||
prompt: "帮我将视频转码为mp4格式"
|
||||
},
|
||||
{
|
||||
title: "音频提取",
|
||||
description: "提取视频中的音频",
|
||||
prompt: "帮我提取视频中的音频"
|
||||
}
|
||||
];
|
||||
|
||||
// 设置相关函数
|
||||
function openSettings() {
|
||||
showSettings = true;
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
showSettings = false;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
localStorage.setItem('ai_settings', JSON.stringify(settings));
|
||||
// 只有当有必要的设置时才创建agent
|
||||
if (settings.api_key && settings.endpoint) {
|
||||
agent = createAgent({
|
||||
apiKey: settings.api_key || undefined,
|
||||
baseURL: settings.endpoint || undefined,
|
||||
model: settings.model || undefined,
|
||||
});
|
||||
// 重新加载模型列表
|
||||
await loadModels();
|
||||
} else {
|
||||
agent = null;
|
||||
}
|
||||
closeSettings();
|
||||
}
|
||||
|
||||
async function fetchModels(endpoint: string, apiKey: string) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/models`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
return data.data.map((model: any) => ({
|
||||
value: model.id,
|
||||
label: model.id
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
if (settings.endpoint && settings.api_key) {
|
||||
isLoadingModels = true;
|
||||
try {
|
||||
const models = await fetchModels(settings.endpoint, settings.api_key);
|
||||
if (models.length > 0) {
|
||||
availableModels = models;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
} finally {
|
||||
isLoadingModels = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const savedSettings = localStorage.getItem('ai_settings');
|
||||
if (savedSettings) {
|
||||
settings = { ...settings, ...JSON.parse(savedSettings) };
|
||||
// 只有当有必要的设置时才创建agent
|
||||
if (settings.api_key && settings.endpoint) {
|
||||
agent = createAgent({
|
||||
apiKey: settings.api_key || undefined,
|
||||
baseURL: settings.endpoint || undefined,
|
||||
model: settings.model || undefined,
|
||||
});
|
||||
// 加载模型列表
|
||||
loadModels();
|
||||
} else {
|
||||
agent = null;
|
||||
}
|
||||
} else {
|
||||
agent = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallState(message: any): 'confirmed' | 'rejected' | 'none' {
|
||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
||||
return toolCallStates.get(message.tool_calls[0]?.id) || 'none';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// 自动滚动到底部
|
||||
function scrollToBottom() {
|
||||
if (messageContainer) {
|
||||
setTimeout(() => {
|
||||
messageContainer.scrollTop = messageContainer.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
async function sendMessage() {
|
||||
if (!inputMessage.trim() || isProcessing) return;
|
||||
|
||||
const userMessage = inputMessage.trim();
|
||||
inputMessage = "";
|
||||
|
||||
const message = new HumanMessage(userMessage);
|
||||
// 为消息添加时间戳
|
||||
message.additional_kwargs = {
|
||||
...message.additional_kwargs,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
await continueAgentFlow([message]);
|
||||
}
|
||||
|
||||
// 点击预设提示词
|
||||
async function handlePresetPrompt(prompt: string) {
|
||||
inputMessage = prompt;
|
||||
await sendMessage();
|
||||
}
|
||||
|
||||
async function continueAgentFlow(newMessages: any[]) {
|
||||
console.log("continueAgentFlow", newMessages);
|
||||
messages = [...messages, ...newMessages];
|
||||
isProcessing = true;
|
||||
try {
|
||||
const result = await agent.invoke(
|
||||
{
|
||||
messages: firstMessage ? messages : newMessages,
|
||||
},
|
||||
{
|
||||
configurable: {
|
||||
thread_id: "chat-session",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log("result", result);
|
||||
|
||||
const newLastMessage = result.messages[result.messages.length - 1];
|
||||
newLastMessage.additional_kwargs = {
|
||||
...newLastMessage.additional_kwargs,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
messages = [
|
||||
...messages,
|
||||
newLastMessage,
|
||||
];
|
||||
|
||||
console.log("messages", messages);
|
||||
|
||||
// if the last message is a tool call, and it is not sensitive
|
||||
if (isToolCall(newLastMessage) && !isSensitiveToolCall(newLastMessage)) {
|
||||
// take it as confirmed
|
||||
handleToolCallConfirm(newLastMessage.tool_calls[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI 处理错误:", error);
|
||||
const errorMessage = new AIMessage("抱歉,处理您的消息时出现了错误。请稍后重试。");
|
||||
errorMessage.additional_kwargs = {
|
||||
...errorMessage.additional_kwargs,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
messages = [
|
||||
...messages,
|
||||
errorMessage,
|
||||
];
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
firstMessage = false;
|
||||
scrollToBottom();
|
||||
|
||||
// save messages into local storage
|
||||
localStorage.setItem('messages', JSON.stringify(messages));
|
||||
// save toolCallStates into local storage
|
||||
const toolCallStatesObj = {};
|
||||
toolCallStates.forEach((value, key) => {
|
||||
toolCallStatesObj[key] = value;
|
||||
});
|
||||
localStorage.setItem('toolCallStates', JSON.stringify(toolCallStatesObj));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理回车键
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function isSensitiveToolCall(message: any) {
|
||||
return message.tool_calls && message.tool_calls.length > 0
|
||||
&& message.tool_calls.some((tool_call: any) => tool_call.name.includes("delete") ||
|
||||
tool_call.name.includes("remove") ||
|
||||
tool_call.name.includes("post") ||
|
||||
tool_call.name.includes("clip") ||
|
||||
tool_call.name.includes("generate") ||
|
||||
tool_call.name.includes("generic_ffmpeg_command"));
|
||||
}
|
||||
|
||||
function isToolCall(message: any) {
|
||||
return message.tool_calls && message.tool_calls.length > 0;
|
||||
}
|
||||
|
||||
// 清空对话
|
||||
async function clearConversation() {
|
||||
console.log("clearConversation");
|
||||
messages = [];
|
||||
toolCallStates.clear();
|
||||
localStorage.removeItem('messages');
|
||||
localStorage.removeItem('toolCallStates');
|
||||
// 只有当有必要的设置时才重新创建agent
|
||||
if (settings.api_key && settings.endpoint) {
|
||||
agent = createAgent({
|
||||
apiKey: settings.api_key || undefined,
|
||||
baseURL: settings.endpoint || undefined,
|
||||
model: settings.model || undefined,
|
||||
});
|
||||
}
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleToolCallConfirm(toolCall: any) {
|
||||
console.log("handleToolCallConfirm", toolCall);
|
||||
toolCallStates.set(toolCall.id, 'confirmed');
|
||||
// update messages to trigger re-render
|
||||
messages = [...messages];
|
||||
// Execute the tool and resume the flow
|
||||
await executeToolAndResume(toolCall);
|
||||
}
|
||||
|
||||
async function executeToolAndResume(toolCall: any) {
|
||||
isProcessing = true;
|
||||
try {
|
||||
// Find the tool in the tools array
|
||||
const tool = tools.find(t => t.name === toolCall.name);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool ${toolCall.name} not found`);
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const toolResult = await tool.invoke(toolCall.args);
|
||||
console.log("Tool result:", toolResult);
|
||||
|
||||
// Create a ToolMessage with the result
|
||||
const toolMessage = new ToolMessage({
|
||||
name: toolCall.name,
|
||||
content: JSON.stringify(toolResult),
|
||||
tool_call_id: toolCall.id || `tool_${Date.now()}`,
|
||||
});
|
||||
// 为工具消息添加时间戳
|
||||
toolMessage.additional_kwargs = {
|
||||
...toolMessage.additional_kwargs,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Continue the agent flow with the tool result
|
||||
await continueAgentFlow([toolMessage]);
|
||||
} catch (error) {
|
||||
console.error("Tool execution error:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const resultMessage = new ToolMessage({
|
||||
name: toolCall.name,
|
||||
content: errorMessage,
|
||||
tool_call_id: toolCall.id || `tool_${Date.now()}`,
|
||||
});
|
||||
// 为错误工具消息添加时间戳
|
||||
resultMessage.additional_kwargs = {
|
||||
...resultMessage.additional_kwargs,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
await continueAgentFlow([resultMessage]);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToolCallReject(toolCall: any) {
|
||||
console.log("handleToolCallReject", toolCall);
|
||||
|
||||
toolCallStates.set(toolCall.id, 'rejected');
|
||||
const resultMessage = new ToolMessage({
|
||||
name: toolCall.name,
|
||||
content: "用户选择拒绝执行工具",
|
||||
tool_call_id: toolCall.id || `tool_${Date.now()}`,
|
||||
});
|
||||
// 为拒绝的工具消息添加时间戳
|
||||
resultMessage.additional_kwargs = {
|
||||
...resultMessage.additional_kwargs,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
await continueAgentFlow([resultMessage]);
|
||||
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// 加载设置
|
||||
loadSettings();
|
||||
|
||||
const previousMessages = JSON.parse(localStorage.getItem('messages') || '[]');
|
||||
// reconstruct messages
|
||||
messages = previousMessages.map((message: any) => {
|
||||
// if HumanMessage in messgae.id array
|
||||
if (message.id.includes('HumanMessage')) {
|
||||
const msg = new HumanMessage(message.kwargs);
|
||||
// 恢复时间戳
|
||||
if (message.additional_kwargs?.timestamp) {
|
||||
msg.additional_kwargs = {
|
||||
...msg.additional_kwargs,
|
||||
timestamp: message.additional_kwargs.timestamp
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
} else if (message.id.includes('AIMessage')) {
|
||||
const msg = new AIMessage(message.kwargs);
|
||||
// 恢复时间戳
|
||||
if (message.additional_kwargs?.timestamp) {
|
||||
msg.additional_kwargs = {
|
||||
...msg.additional_kwargs,
|
||||
timestamp: message.additional_kwargs.timestamp
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
} else if (message.id.includes('ToolMessage')) {
|
||||
const msg = new ToolMessage(message.kwargs);
|
||||
// 恢复时间戳
|
||||
if (message.additional_kwargs?.timestamp) {
|
||||
msg.additional_kwargs = {
|
||||
...msg.additional_kwargs,
|
||||
timestamp: message.additional_kwargs.timestamp
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
});
|
||||
console.log("init messages", messages);
|
||||
// init toolCallStates
|
||||
toolCallStates.clear();
|
||||
const toolCallStatesString = localStorage.getItem('toolCallStates');
|
||||
console.log("toolCallStatesString", toolCallStatesString);
|
||||
if (toolCallStatesString) {
|
||||
const toolCallStatesObj = JSON.parse(toolCallStatesString);
|
||||
for (const [key, value] of Object.entries(toolCallStatesObj)) {
|
||||
toolCallStates.set(key, value as 'confirmed' | 'rejected' | 'none');
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 flex flex-col bg-gray-50 dark:bg-gray-900 h-full">
|
||||
<!-- Messages Container -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto p-6 space-y-4 custom-scrollbar-light min-h-0"
|
||||
bind:this={messageContainer}
|
||||
>
|
||||
{#if !agent}
|
||||
<div class="flex items-center justify-center min-h-[400px] px-6">
|
||||
<div class="max-w-sm w-full">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-8 shadow-lg shadow-blue-500/20">
|
||||
<Settings class="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4 tracking-tight">
|
||||
配置 AI 模型
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-8 leading-relaxed text-base">
|
||||
在使用 AI 助手之前,请先配置您的 OpenAI 兼容 API 设置。
|
||||
</p>
|
||||
<button
|
||||
class="inline-flex items-center justify-center space-x-2 px-8 py-4 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-xl transition-all duration-200 font-medium text-base shadow-sm hover:shadow-md active:shadow-inner disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
on:click={openSettings}
|
||||
>
|
||||
<Settings class="w-5 h-5" />
|
||||
<span>模型设置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if messages.length === 0}
|
||||
<div class="text-center py-10">
|
||||
<p class="text-gray-500 dark:text-gray-400 text-lg mb-8">
|
||||
我是助手小轴,你可以点击下方预设提示词发送第一条消息,或是直接输入你想要执行的操作。
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||
{#each presetPrompts as prompt}
|
||||
<button
|
||||
class="group relative p-4 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-2xl text-left transition-all duration-200 hover:bg-white/90 dark:hover:bg-gray-800/90 hover:shadow-lg hover:shadow-gray-200/50 dark:hover:shadow-gray-900/50 hover:scale-[1.02] active:scale-[0.98]"
|
||||
on:click={() => handlePresetPrompt(prompt.prompt)}
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center group-hover:from-blue-600 group-hover:to-purple-700 transition-all duration-200">
|
||||
<Sparkles class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
|
||||
{prompt.title}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">
|
||||
{prompt.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message, index (index)}
|
||||
{#if message instanceof HumanMessage}
|
||||
<HumanMessageComponent {message} {formatTime} />
|
||||
{:else if message instanceof AIMessage}
|
||||
<AIMessageComponent
|
||||
{message}
|
||||
{formatTime}
|
||||
onToolCallConfirm={handleToolCallConfirm}
|
||||
onToolCallReject={handleToolCallReject}
|
||||
toolCallState={getToolCallState(message)}
|
||||
isSensitiveToolCall={isSensitiveToolCall(message)}
|
||||
/>
|
||||
{:else if message instanceof ToolMessage}
|
||||
<ToolMessageComponent {message} {formatTime} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if isProcessing}
|
||||
<ProcessingMessageComponent />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-stretch space-x-3">
|
||||
<div class="flex-1 flex items-center">
|
||||
<textarea
|
||||
bind:value={inputMessage}
|
||||
on:keypress={handleKeyPress}
|
||||
placeholder={!agent ? "请先配置 AI 模型..." : "输入您的消息..."}
|
||||
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none min-h-[44px] max-h-[120px] text-sm"
|
||||
rows="1"
|
||||
disabled={isProcessing || !agent}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-4 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 text-sm font-medium"
|
||||
disabled={!inputMessage.trim() || isProcessing || !agent}
|
||||
on:click={sendMessage}
|
||||
>
|
||||
<Send class="w-4 h-4" />
|
||||
<span>发送</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg border border-gray-300 dark:border-gray-600 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
on:click={clearConversation}
|
||||
disabled={!agent}
|
||||
>
|
||||
清空对话
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
|
||||
on:click={openSettings}
|
||||
title="设置"
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
{#if showSettings}
|
||||
<div class="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white/95 dark:bg-gray-800/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 w-full max-w-md mx-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white tracking-tight">模型设置</h3>
|
||||
<button
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition-all duration-200"
|
||||
on:click={closeSettings}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label for="endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
OpenAI Compatible API Endpoint
|
||||
</label>
|
||||
<input
|
||||
id="endpoint"
|
||||
type="text"
|
||||
bind:value={settings.endpoint}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
id="api_key"
|
||||
type="password"
|
||||
bind:value={settings.api_key}
|
||||
placeholder="sk-..."
|
||||
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
模型选择
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed px-2 py-1 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all duration-200"
|
||||
on:click={loadModels}
|
||||
disabled={!settings.endpoint || !settings.api_key || isLoadingModels}
|
||||
>
|
||||
{isLoadingModels ? '加载中...' : '刷新模型列表'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="model"
|
||||
type="text"
|
||||
bind:value={settings.model}
|
||||
list="model-options"
|
||||
placeholder="输入模型名称或从列表中选择"
|
||||
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
/>
|
||||
<datalist id="model-options">
|
||||
{#each availableModels as model}
|
||||
<option value={model.value}>{model.label}</option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-8">
|
||||
<button
|
||||
class="px-6 py-3 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl border border-gray-300 dark:border-gray-600 transition-all duration-200 font-medium"
|
||||
on:click={closeSettings}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-3 text-sm bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md active:shadow-inner"
|
||||
on:click={saveSettings}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||