Compare commits

...

64 Commits

Author SHA1 Message Date
Xinrea
c4592d5ca6 bump version to 2.0.3 2025-03-24 19:31:53 +08:00
Xinrea
5e9dba5d58 Merge pull request #49 from Xinrea/fix/outdated_stream
fix: outdated bilibili stream (close #44)
2025-03-24 19:06:58 +08:00
Xinrea
2cfd140d4a fix: outdated bilibili stream (close #44)
之前当直播流获取内容出现错误时,会尝试重新获取直播间状态,当检测到直播间关闭则会放弃继续尝试;
而如果直播间快速关播开播,原直播流虽然无新内容会触发Error::FreezedStream,但是由于流未过期,
check_status 不会更新直播流,导致 recorder 无法切换到新的直播流。

更改后,将流过期的判断放在 update_entries 中,如果遇到任何错误,便会尝试重新获取新的流地址。
2025-03-24 19:06:00 +08:00
Xinrea
13e421bfba Merge pull request #47 from Xinrea/fix/macos-crash
fix: macOS crash caused by log and ffmpeg_sidecar (close #45)
2025-03-24 12:47:38 +08:00
Xinrea
1933727f89 fix: macOS crash caused by log and ffmpeg_sidecar (close #45) 2025-03-24 12:42:38 +08:00
Xinrea
4e16bfcc18 Merge pull request #41 from Xinrea/fix/infinite-nested-dirs
fix: infinite nested dirs
2025-03-17 20:52:30 +08:00
Xinrea
398ee831de bump version to 2.0.2 2025-03-17 20:51:21 +08:00
Xinrea
6d01280039 fix: infinite nested dirs when change output/cache folder 2025-03-17 20:50:16 +08:00
Xinrea
8aaa701348 bump version to 2.0.1 2025-03-15 13:11:03 +08:00
Xinrea
733a36571b feat: provide more detailed error information for 'add-room' failures (close #38) 2025-03-15 13:06:38 +08:00
Xinrea
f40ac28781 fix: account removal 2025-03-15 13:00:57 +08:00
Xinrea
d73e95d2e5 feat: cache migration 2025-03-14 11:31:04 +08:00
Xinrea
770338a68a fix: auto-close for donate modal 2025-03-13 11:36:17 +08:00
Xinrea
f58dafbde8 fix: video frame seek on windows 2025-03-13 01:30:35 +08:00
Xinrea
004712e851 feat: fix bilibili clip 2025-03-13 01:22:37 +08:00
Xinrea
4e53ed2cf8 fix: work dir path on windows 2025-03-13 01:12:33 +08:00
Xinrea
92ccad6253 fix: enable window decoration on windows 2025-03-13 01:11:29 +08:00
Xinrea
74c5e9bb09 Merge pull request #36 from Xinrea/feat/new-ui
v2.0.0 版本功能更新
2025-03-13 00:29:37 +08:00
Xinrea
2724d6b4d3 doc: update 2025-03-13 00:15:19 +08:00
Xinrea
212e144422 refactor: adjust player init order 2025-03-13 00:04:56 +08:00
Xinrea
205a1b82e7 bump version to 2.0.0 2025-03-12 23:51:51 +08:00
Xinrea
44b4604581 feat: add checks before deleting archive 2025-03-12 23:48:21 +08:00
Xinrea
3d3454b5a4 feat: advanced cover editor and donate page 2025-03-12 23:36:54 +08:00
Xinrea
67f1b04b67 feat: douyin support 2025-03-12 19:39:32 +08:00
Xinrea
fd7d299e55 refactor: decouple recorder 2025-03-10 19:21:59 +08:00
Xinrea
ada492f3f0 refactor: new ui 2025-03-10 19:21:43 +08:00
Xinrea
8a4e4fd32b fix: using first account when primary missing
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-23 00:52:57 +08:00
Xinrea
86ced2a217 release: bump version to 1.4.2
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:22:34 +08:00
Xinrea
c62251dfe9 fix(livewindow): adjust titlebar index to avoid blocking buttons
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:21:42 +08:00
Xinrea
8bf0f5d36e fix(recorder_manager): unable to remove recorder that has no cache folder created yet
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:10:02 +08:00
Xinrea
a4b6567947 feat(main, recorder): remove unused ffmpeg dependency
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:10:02 +08:00
Xinrea
6c5c628bbf fix(recorder): reconnect danmu ws after disconnection
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:10:02 +08:00
Xinrea
1d6593340d fix(account,room): duplicated avatar when adding/removing items
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-03 01:04:08 +08:00
Xinrea
0d992d205f release: bump version to 1.4.1
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-02 00:39:08 +08:00
Xinrea
f82e79efd4 Merge pull request #32 from Xinrea/feat/quick-stream-switch
feat(player): add shortcut button to navigate to other live rooms
2024-12-02 00:36:49 +08:00
Xinrea
5cdb6b6f75 feat(player): add shortcut button to navigate to other live rooms (close #31)
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-02 00:36:22 +08:00
Xinrea
7316a022be feat(player): enable player lowlatency mode
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-02 00:32:31 +08:00
Xinrea
dbd8a29b73 feat(recorder): dynamic entry update interval
Sometimes entry update cost a lot of time (caused by timeout or something),
which makes the actually interval much larger than 1s.

In this commit, entry update interval is ranged from 0ms to 500ms, based on
the time cost by last entry-updating.

Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-02 00:28:40 +08:00
Xinrea
d6a5a02d68 fix(recorder): optimize lock range and download sequence for entries
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-01 21:58:53 +08:00
Xinrea
9eef00b913 fix(bilibili): adjust global timeout to 10s
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-01 21:10:57 +08:00
Xinrea
3b9dd4824b feat: migrate tauri from rc to release
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-01 19:01:41 +08:00
Xinrea
902c1ad39e chore: add feature issue template 2024-12-01 03:28:20 +08:00
Xinrea
b4f6dea97f chore: add bug issue template 2024-12-01 03:20:36 +08:00
Xinrea
c1c252f54a release: bump version to 1.4.0
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-01 01:29:59 +08:00
Xinrea
303b5f8847 Merge pull request #30 from Xinrea/feat/danmu-statistic
feat(player): add danmu statistics graph on top of seek bar (close #17)
2024-12-01 01:28:38 +08:00
Xinrea
26f55a463b feat(player): add danmu statistics graph on top of seek bar (close #17)
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-01 01:15:05 +08:00
Xinrea
0fa2c366dc fix(recorder): add retry for entry downloading to avoid gaps
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-01 01:14:03 +08:00
Xinrea
bf1588e414 fix(recorder): potential panic when reopen live window
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-30 21:53:21 +08:00
Xinrea
3be0f25dfc fix(bilibili): add extra info when create index url to avoid 403
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-30 20:49:27 +08:00
Xinrea
0e53028922 fix(recorder_manager): always return error when deleting archive
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-29 02:14:57 +08:00
Xinrea
bc458647a3 release: bump version to 1.3.1
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 23:16:42 +08:00
Xinrea
6c5080394a refactor: address clippy warnings
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 16:24:57 +08:00
Xinrea
30d45ca2c3 refactor(database): modularize database implementation
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 15:58:46 +08:00
Xinrea
614aa3184f fix(player): use 'p' as marker shortcut to avoid conflicts (close #29)
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 15:36:28 +08:00
Xinrea
cacd28bd87 release: bump version to 1.3.0
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 00:54:26 +08:00
Xinrea
a3dfe86a04 doc: update livewindow description in README
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 00:51:23 +08:00
Xinrea
79c65cab63 Merge pull request #28 from Xinrea/feat/timepoint-mark
时间点标记功能
2024-11-26 00:42:47 +08:00
Xinrea
52237b9385 feat(livewindow): add timepoint mark support
By the way, livewindow style change to dark theme.

Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 00:38:21 +08:00
Xinrea
b8d22e92ff feat(livewindow): add collapse button for post panel
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-25 14:56:08 +08:00
Xinrea
faac0e29b5 fix(recorder): prevent deletion of current stream archive
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-25 02:11:46 +08:00
Xinrea
899afac910 feat(livewindow): support selection of video post area (close #20)
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-25 02:10:53 +08:00
Xinrea
7b2cbfefcc fix(recorder): safely stop recorder when removing room
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-25 01:57:20 +08:00
Xinrea
793e532240 feat(bilibili): add video type list api
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-24 21:47:10 +08:00
Xinrea
68c7bd251e refactor(bilibili): move response struct into mod response
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-24 20:39:21 +08:00
78 changed files with 9728 additions and 5346 deletions

27
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,27 @@
---
name: Bug report
about: 提交一个 BUG
title: "[BUG]"
labels: bug
assignees: Xinrea
---
**描述:**
简要描述一下这个 BUG 的现象
**如何遇到:**
遇到这个 BUG 之前进行了哪些操作
**期望:**
如果执行相同的操作,期望发生什么
**日志和截图:**
如果可以的话,请尽量附上相关截图和日志文件(日志是位于安装目录下,名为 bsr.log 的文件)。
**相关信息:**
- 程序版本:
- 系统类型:
**其他**
任何其他想说的

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: 提交一个新功能的建议
title: "[feature]"
labels: enhancement
assignees: Xinrea
---
**遇到的问题:**
在使用过程中遇到了什么问题让你想要提出建议
**想要的功能:**
想要怎样的新功能来解决这个问题
**通过什么方式实现(有思路的话):**
如果有相关的实现思路或者是参考,可以在此提供
**其他:**
其他任何想说的话

View File

@@ -6,10 +6,12 @@
![GitHub Release](https://img.shields.io/github/v/release/xinrea/bili-shadowreplay)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/xinrea/bili-shadowreplay/total)
BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
> [!WARNING]
> v2.0.0 版本为重大更新,将不兼容 v1.x 版本的数据。
> [!NOTE]
> 由于软件在快速开发中,截图说明可能有变动,仅供参考
BiliBili ShadowReplay 是一个缓存直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
目前仅支持 B 站和抖音平台的直播。
![rooms](doc/summary.png)
@@ -17,8 +19,6 @@ BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的
![rooms](doc/summary.png)
显示直播缓存的占用以及缓存所在磁盘的使用情况。
## 直播间管理
![clip](doc/rooms.png)
@@ -30,30 +30,34 @@ BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的
无论是正在进行的直播还是历史录播,都可在预览窗口进行回放,同时也可以进行切片编辑以及投稿。关于预览窗口的相关说明请见 [预览窗口](#预览窗口)。
## 消息管理
![messages](doc/messages.png)
执行的各种操作都会留下消息记录,方便查看过去进行的操作。
## 账号管理
![accounts](doc/accounts.png)
程序需要至少一个账号用于直播流以及用户信息的获取,可以在此页面添加账号。目前添加账号仅支持 B 站手机 App 扫码添加。
程序需要至少一个账号用于直播流以及用户信息的获取,可以在此页面添加账号。
你可以添加多个账号,但只有一个账号会被标记为主账号,主账号用于直播流的获取。所有账号都可在切片投稿或是观看直播流发送弹幕时自由选择,详情见 [预览窗口](#预览窗口)。
抖音账号目前仅支持手动 Cookie 添加,且账号仅用于获取直播信息和直播流。
## 预览窗口
![livewindow](doc/livewindow.png)
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片、记录时间点以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
通过预览窗口的快捷键操作,可以快速选择时间区间,进行切片生成以及投稿。
无论是弹幕发送还是投稿,均可自由选择账号,只要在账号管理中添加了该账号。
进度条上方会显示弹幕频率图,可以直观地看到弹幕的分布情况;右侧的弹幕统计过滤器可以用于过滤弹幕,只显示含有指定文字的弹幕的统计情况。
## 封面编辑
![cover](doc/coveredit.png)
在预览窗口中,生成切片后可以进行封面编辑,包括关键帧的选择、文字的添加和拖动等。
## 设置
![settings](doc/settings.png)
@@ -61,4 +65,4 @@ BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
> [!WARNING]
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。缓存切换开始和结束均会在消息管理中有记录。
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
doc/coveredit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 721 KiB

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-cn">
<html lang="zh-cn" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
@@ -18,6 +18,38 @@
height: 12px; /* 设置滑块按钮高度 */
border-radius: 50%; /* 设置为圆形 */
}
html {
scrollbar-face-color: #646464;
scrollbar-base-color: #646464;
scrollbar-3dlight-color: #646464;
scrollbar-highlight-color: #646464;
scrollbar-track-color: #000;
scrollbar-arrow-color: #000;
scrollbar-shadow-color: #646464;
scrollbar-dark-shadow-color: #646464;
}
::-webkit-scrollbar {
width: 8px;
height: 3px;
}
::-webkit-scrollbar-button {
background-color: #666;
}
::-webkit-scrollbar-track {
background-color: #646464;
}
::-webkit-scrollbar-track-piece {
background-color: #000;
}
::-webkit-scrollbar-thumb {
height: 50px;
background-color: #666;
border-radius: 3px;
}
::-webkit-scrollbar-corner {
background-color: #646464;
}
</style>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "bili-shadowreplay",
"private": true,
"version": "1.2.1",
"version": "2.0.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,20 +11,21 @@
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "2.0.0-rc.0",
"@tauri-apps/plugin-dialog": "^2.0.0-rc.1",
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
"@tauri-apps/plugin-http": "^2.0.0-rc.2",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-notification": "~2",
"@tauri-apps/plugin-os": "^2.0.0-rc",
"@tauri-apps/plugin-shell": "^2.0.0-rc.1",
"@tauri-apps/plugin-sql": "^2.0.0-rc.1",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-shell": "~2",
"@tauri-apps/plugin-sql": "~2",
"html2canvas": "^1.4.1",
"lucide-svelte": "^0.479.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.0",
"@tauri-apps/cli": "^2.0.2",
"@tauri-apps/cli": "^2.1.0",
"@tsconfig/svelte": "^3.0.0",
"@types/node": "^18.7.10",
"@types/qrcode": "^1.5.5",

BIN
public/imgs/donate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

BIN
public/imgs/douyin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

299
src-tauri/Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@@ -316,9 +316,9 @@ checksum = "7a40729d2133846d9ed0ea60a8b9541bccddab49cd30f0715a1da672fe9a2524"
[[package]]
name = "async-trait"
version = "0.1.68"
version = "0.1.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97"
dependencies = [
"proc-macro2",
"quote",
@@ -407,6 +407,8 @@ name = "bili-shadowreplay"
version = "1.0.0"
dependencies = [
"async-std",
"async-trait",
"base64 0.21.0",
"chrono",
"custom_error",
"dashmap",
@@ -417,6 +419,7 @@ dependencies = [
"log",
"m3u8-rs",
"md5",
"mime_guess",
"notify-rust",
"pct-str",
"platform-dirs",
@@ -1196,17 +1199,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.76",
]
[[package]]
name = "dlib"
version = "0.5.2"
@@ -2376,124 +2368,6 @@ dependencies = [
"png",
]
[[package]]
name = "icu_collections"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locid"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locid_transform"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
dependencies = [
"displaydoc",
"icu_locid",
"icu_locid_transform_data",
"icu_provider",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locid_transform_data"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
[[package]]
name = "icu_normalizer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
dependencies = [
"displaydoc",
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"utf16_iter",
"utf8_iter",
"write16",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
[[package]]
name = "icu_properties"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locid_transform",
"icu_properties_data",
"icu_provider",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
[[package]]
name = "icu_provider"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
dependencies = [
"displaydoc",
"icu_locid",
"icu_provider_macros",
"stable_deref_trait",
"tinystr",
"writeable",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_provider_macros"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.76",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -2520,27 +2394,6 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "idna"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "1.9.2"
@@ -2846,12 +2699,6 @@ version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
[[package]]
name = "litemap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704"
[[package]]
name = "lock_api"
version = "0.4.12"
@@ -2975,6 +2822,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -5203,17 +5060,6 @@ dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.76",
]
[[package]]
name = "sys-locale"
version = "0.3.1"
@@ -5810,16 +5656,6 @@ dependencies = [
"time-core",
]
[[package]]
name = "tinystr"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.6.0"
@@ -6163,6 +5999,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
version = "0.3.12"
@@ -6216,12 +6058,12 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.3"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada"
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
dependencies = [
"form_urlencoded",
"idna 1.0.3",
"idna 0.5.0",
"percent-encoding",
"serde",
]
@@ -6250,24 +6092,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8-decode"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca61eb27fa339aa08826a29f03e87b99b4d8f0fc2255306fd266bb1b6a9de498"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.3.0"
@@ -7122,18 +6952,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
[[package]]
name = "writeable"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]]
name = "wry"
version = "0.44.1"
@@ -7205,30 +7023,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "yoke"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5"
dependencies = [
"serde",
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.76",
"synstructure",
]
[[package]]
name = "zbus"
version = "4.0.1"
@@ -7314,55 +7108,12 @@ dependencies = [
"syn 2.0.76",
]
[[package]]
name = "zerofrom"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.76",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
[[package]]
name = "zerovec"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.76",
]
[[package]]
name = "zvariant"
version = "4.0.0"

View File

@@ -10,10 +10,10 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.1", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2.0.1", features = ["protocol-asset", "tray-icon"] }
tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde_derive = "1.0.158"
@@ -39,15 +39,18 @@ urlencoding = "2.1.3"
log = "0.4.22"
simplelog = "0.12.2"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
tauri-plugin-dialog = "2.0.1"
tauri-plugin-shell = "2.0.1"
tauri-plugin-fs = "2.0.1"
tauri-plugin-http = "2.0.1"
tauri-utils = "2.0.1"
tauri-plugin-sql = { version = "2.0.1", features = ["sqlite"] }
tauri-plugin-os = "2.0.1"
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
tauri-utils = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-os = "2"
tauri-plugin-notification = "2"
rand = "0.8.5"
base64 = "0.21"
mime_guess = "2.0"
async-trait = "0.1.87"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
@@ -55,4 +58,4 @@ rand = "0.8.5"
custom-protocol = ["tauri/custom-protocol"]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-single-instance = "2"

View File

@@ -48,6 +48,12 @@
},
{
"url": "https://*.afdiancdn.com/"
},
{
"url": "https://*.douyin.com/"
},
{
"url": "https://*.douyinpic.com/"
}
]
},
@@ -57,6 +63,12 @@
"http:default",
"sql:default",
"os:default",
"notification:default"
"notification:default",
"dialog:default",
"fs:default",
"http:default",
"shell:default",
"sql:default",
"os:default"
]
}

View File

@@ -1 +1 @@
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*"],"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/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default"]}}
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*"],"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"]}}

75
src-tauri/src/config.rs Normal file
View File

@@ -0,0 +1,75 @@
use platform_dirs::AppDirs;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Clone)]
pub struct Config {
pub cache: String,
pub output: String,
pub primary_uid: u64,
pub webid: String,
pub webid_ts: i64,
pub live_start_notify: bool,
pub live_end_notify: bool,
pub clip_notify: bool,
pub post_notify: bool,
}
impl Config {
pub fn load() -> Self {
let app_dirs = AppDirs::new(Some("cn.vjoi.bili-shadowreplay"), false).unwrap();
let config_path = app_dirs.config_dir.join("Conf.toml");
if let Ok(content) = std::fs::read_to_string(config_path) {
if let Ok(config) = toml::from_str(&content) {
return config;
}
}
let config = Config {
webid: "".to_string(),
webid_ts: 0,
cache: app_dirs
.cache_dir
.join("cache")
.to_str()
.unwrap()
.to_string(),
output: app_dirs
.data_dir
.join("output")
.to_str()
.unwrap()
.to_string(),
primary_uid: 0,
live_start_notify: true,
live_end_notify: true,
clip_notify: true,
post_notify: true,
};
config.save();
config
}
pub fn save(&self) {
let content = toml::to_string(&self).unwrap();
let app_dirs = AppDirs::new(Some("cn.vjoi.bili-shadowreplay"), false).unwrap();
// Create app dirs if not exists
std::fs::create_dir_all(&app_dirs.config_dir).unwrap();
let config_path = app_dirs.config_dir.join("Conf.toml");
std::fs::write(config_path, content).unwrap();
}
pub fn set_cache_path(&mut self, path: &str) {
self.cache = path.to_string();
self.save();
}
pub fn set_output_path(&mut self, path: &str) {
self.output = path.into();
self.save();
}
pub fn webid_expired(&self) -> bool {
let now = chrono::Utc::now().timestamp();
// expire in 20 hours
now - self.webid_ts > 72000
}
}

47
src-tauri/src/database.rs Normal file
View File

@@ -0,0 +1,47 @@
use custom_error::custom_error;
use sqlx::Pool;
use sqlx::Sqlite;
use tokio::sync::RwLock;
pub mod account;
pub mod message;
pub mod record;
pub mod recorder;
pub mod video;
pub struct Database {
db: RwLock<Option<Pool<Sqlite>>>,
}
custom_error! { pub DatabaseError
InsertError = "Entry insert failed",
NotFoundError = "Entry not found",
InvalidCookiesError = "Cookies are invalid",
DBError {err: sqlx::Error } = "DB error: {err}",
SQLError { sql: String } = "SQL is incorret: {sql}"
}
impl From<DatabaseError> for String {
fn from(value: DatabaseError) -> Self {
value.to_string()
}
}
impl From<sqlx::Error> for DatabaseError {
fn from(value: sqlx::Error) -> Self {
DatabaseError::DBError { err: value }
}
}
impl Database {
pub fn new() -> Database {
Database {
db: RwLock::new(None),
}
}
/// db *must* be set in tauri setup
pub async fn set(&self, p: Pool<Sqlite>) {
*self.db.write().await = Some(p);
}
}

View File

@@ -0,0 +1,141 @@
use crate::recorder::PlatformType;
use super::Database;
use super::DatabaseError;
use chrono::Utc;
use rand::Rng;
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct AccountRow {
pub platform: String,
pub uid: u64,
pub name: String,
pub avatar: String,
pub csrf: String,
pub cookies: String,
pub created_at: String,
}
// accounts
impl Database {
// CREATE TABLE accounts (uid INTEGER PRIMARY KEY, name TEXT, avatar TEXT, csrf TEXT, cookies TEXT, created_at TEXT);
pub async fn add_account(&self, platform: &str, cookies: &str) -> Result<AccountRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let platform = PlatformType::from_str(platform).unwrap();
let csrf = if platform == PlatformType::Douyin {
Some("".to_string())
} else {
// parse cookies
cookies
.split(';')
.map(|cookie| cookie.trim())
.find_map(|cookie| -> Option<String> {
match cookie.starts_with("bili_jct=") {
true => {
let var_name = &"bili_jct=";
Some(cookie[var_name.len()..].to_string())
}
false => None,
}
})
};
if csrf.is_none() {
return Err(DatabaseError::InvalidCookiesError);
}
// parse uid
let uid = if platform == PlatformType::BiliBili {
cookies
.split("DedeUserID=")
.collect::<Vec<&str>>()
.get(1)
.unwrap()
.split(";")
.collect::<Vec<&str>>()
.first()
.unwrap()
.to_string()
.parse::<u64>()
.map_err(|_| DatabaseError::InvalidCookiesError)?
} else {
// generate a random uid
rand::thread_rng().gen_range(10000..=i32::MAX) as u64
};
let account = AccountRow {
platform: platform.as_str().to_string(),
uid,
name: "".into(),
avatar: "".into(),
csrf: csrf.unwrap(),
cookies: cookies.into(),
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?;
Ok(account)
}
pub async fn remove_account(&self, platform: &str, uid: u64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("DELETE FROM accounts WHERE uid = $1 and platform = $2")
.bind(uid as i64)
.bind(platform)
.execute(&lock)
.await?;
if sql.rows_affected() != 1 {
return Err(DatabaseError::NotFoundError);
}
Ok(())
}
pub async fn update_account(
&self,
platform: &str,
uid: u64,
name: &str,
avatar: &str,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3 and platform = $4")
.bind(name)
.bind(avatar)
.bind(uid as i64)
.bind(platform)
.execute(&lock)
.await?;
if sql.rows_affected() != 1 {
return Err(DatabaseError::NotFoundError);
}
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")
.fetch_all(&lock)
.await?)
}
pub async fn get_account(&self, platform: &str, uid: u64) -> Result<AccountRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(
sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE uid = $1 and platform = $2")
.bind(uid as i64)
.bind(platform)
.fetch_one(&lock)
.await?,
)
}
pub async fn get_account_by_platform(&self, platform: &str) -> Result<AccountRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE platform = $1")
.bind(platform)
.fetch_one(&lock)
.await?)
}
}

View File

@@ -0,0 +1,55 @@
use super::Database;
use super::DatabaseError;
use chrono::Utc;
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct MessageRow {
pub id: i64,
pub title: String,
pub content: String,
pub read: u8,
pub created_at: String,
}
// messages
// CREATE TABLE messages (id INTEGER PRIMARY KEY, title TEXT, content TEXT, read INTEGER, created_at TEXT);
impl Database {
pub async fn new_message(&self, title: &str, content: &str) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query(
"INSERT INTO messages (title, content, read, created_at) VALUES ($1, $2, 0, $3)",
)
.bind(title)
.bind(content)
.bind(Utc::now().to_rfc3339())
.execute(&lock)
.await?;
Ok(())
}
pub async fn read_message(&self, id: i64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE messages SET read = $1 WHERE id = $2")
.bind(1)
.bind(id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn delete_message(&self, id: i64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("DELETE FROM messages WHERE id = $1")
.bind(id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn get_messages(&self) -> Result<Vec<MessageRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, MessageRow>("SELECT * FROM messages;")
.fetch_all(&lock)
.await?)
}
}

View File

@@ -0,0 +1,121 @@
use crate::recorder::PlatformType;
use super::Database;
use super::DatabaseError;
use chrono::Utc;
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct RecordRow {
pub platform: String,
pub live_id: String,
pub room_id: u64,
pub title: String,
pub length: i64,
pub size: i64,
pub created_at: String,
pub cover: Option<String>,
}
// 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> {
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?,
)
}
pub async fn get_record(&self, room_id: u64, live_id: &str) -> 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",
)
.bind(live_id)
.bind(room_id as i64)
.fetch_one(&lock)
.await?)
}
pub async fn add_record(
&self,
platform: PlatformType,
live_id: &str,
room_id: u64,
title: &str,
cover: Option<String>,
) -> Result<RecordRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let record = RecordRow {
platform: platform.as_str().to_string(),
live_id: live_id.to_string(),
room_id,
title: title.into(),
length: 0,
size: 0,
created_at: Utc::now().to_rfc3339(),
cover,
};
if let Err(e) = sqlx::query("INSERT INTO records (live_id, room_id, title, length, size, cover, created_at, platform) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)").bind(record.live_id.clone())
.bind(record.room_id as i64).bind(&record.title).bind(0).bind(0).bind(&record.cover).bind(&record.created_at).bind(platform.as_str().to_string()).execute(&lock).await {
// if the record already exists, return the existing record
if e.to_string().contains("UNIQUE constraint failed") {
return self.get_record(room_id, live_id).await;
}
}
Ok(record)
}
pub async fn remove_record(&self, live_id: &str) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("DELETE FROM records WHERE live_id = $1")
.bind(live_id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn update_record(
&self,
live_id: &str,
length: i64,
size: u64,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE records SET length = $1, size = $2 WHERE live_id = $3")
.bind(length)
.bind(size as i64)
.bind(live_id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn get_total_length(&self) -> Result<i64, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let result: (i64,) = sqlx::query_as("SELECT SUM(length) FROM records;")
.fetch_one(&lock)
.await?;
Ok(result.0)
}
pub async fn get_today_record_count(&self) -> Result<i64, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM records WHERE created_at >= $1;")
.bind(Utc::now().date_naive().and_hms_opt(0, 0, 0).unwrap().to_string())
.fetch_one(&lock)
.await?;
Ok(result.0)
}
pub async fn get_recent_record(&self, 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?)
}
}

View File

@@ -0,0 +1,62 @@
use super::Database;
use super::DatabaseError;
use chrono::Utc;
use crate::recorder::PlatformType;
/// Recorder in database is pretty simple
/// because many room infos are collected in realtime
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct RecorderRow {
pub room_id: u64,
pub created_at: String,
pub platform: String,
}
// recorders
impl Database {
pub async fn add_recorder(&self, platform: PlatformType, room_id: u64) -> Result<RecorderRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let recorder = RecorderRow {
room_id,
created_at: Utc::now().to_rfc3339(),
platform: platform.as_str().to_string(),
};
let _ = sqlx::query("INSERT INTO recorders (room_id, created_at, platform) VALUES ($1, $2, $3)")
.bind(room_id as i64)
.bind(&recorder.created_at)
.bind(platform.as_str())
.execute(&lock)
.await?;
Ok(recorder)
}
pub async fn remove_recorder(&self, room_id: u64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("DELETE FROM recorders WHERE room_id = $1")
.bind(room_id as i64)
.execute(&lock)
.await?;
if sql.rows_affected() != 1 {
return Err(DatabaseError::NotFoundError);
}
// remove related archive
let _ = self.remove_archive(room_id).await;
Ok(())
}
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 FROM recorders")
.fetch_all(&lock)
.await?)
}
pub async fn remove_archive(&self, room_id: u64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let _ = sqlx::query("DELETE FROM records WHERE room_id = $1")
.bind(room_id as i64)
.execute(&lock)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,100 @@
use super::Database;
use super::DatabaseError;
// CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id INTEGER, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT);
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct VideoRow {
pub id: i64,
pub room_id: u64,
pub cover: String,
pub file: String,
pub length: i64,
pub size: i64,
pub status: i64,
pub bvid: String,
pub title: String,
pub desc: String,
pub tags: String,
pub area: i64,
pub created_at: String,
}
impl Database {
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
.bind(room_id as i64)
.fetch_all(&lock)
.await?,
)
}
pub async fn get_video(&self, id: i64) -> Result<VideoRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
.bind(id)
.fetch_one(&lock)
.await?,
)
}
pub async fn update_video(&self, video_row: &VideoRow) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7")
.bind(video_row.status)
.bind(&video_row.bvid)
.bind(&video_row.title)
.bind(&video_row.desc)
.bind(&video_row.tags)
.bind(video_row.area)
.bind(video_row.id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn delete_video(&self, id: i64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("DELETE FROM videos WHERE id = $1")
.bind(id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn add_video(&self, video: &VideoRow) -> Result<VideoRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, length, size, status, bvid, title, desc, tags, area, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)")
.bind(video.room_id as i64)
.bind(&video.cover)
.bind(&video.file)
.bind(video.length)
.bind(video.size)
.bind(video.status)
.bind(&video.bvid)
.bind(&video.title)
.bind(&video.desc)
.bind(&video.tags)
.bind(video.area)
.bind(&video.created_at)
.execute(&lock)
.await?;
let video = VideoRow {
id: sql.last_insert_rowid(),
..video.clone()
};
Ok(video)
}
pub async fn update_video_cover(&self, id: i64, cover: String) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE videos SET cover = $1 WHERE id = $2")
.bind(cover)
.bind(id)
.execute(&lock)
.await?;
Ok(())
}
}

View File

@@ -1,452 +0,0 @@
use chrono::Utc;
use custom_error::custom_error;
use sqlx::Pool;
use sqlx::Sqlite;
use tokio::sync::RwLock;
pub struct Database {
db: RwLock<Option<Pool<Sqlite>>>,
}
/// Recorder in database is pretty simple
/// because many room infos are collected in realtime
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct RecorderRow {
pub room_id: u64,
pub created_at: String,
}
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct AccountRow {
pub uid: u64,
pub name: String,
pub avatar: String,
pub csrf: String,
pub cookies: String,
pub created_at: String,
}
custom_error! { pub DatabaseError
InsertError = "Entry insert failed",
NotFoundError = "Entry not found",
InvalidCookiesError = "Cookies are invalid",
DBError {err: sqlx::Error } = "DB error: {err}",
SQLError { sql: String } = "SQL is incorret: {sql}"
}
impl From<DatabaseError> for String {
fn from(value: DatabaseError) -> Self {
value.to_string()
}
}
impl From<sqlx::Error> for DatabaseError {
fn from(value: sqlx::Error) -> Self {
DatabaseError::DBError { err: value }
}
}
impl Database {
pub fn new() -> Database {
Database {
db: RwLock::new(None),
}
}
/// db *must* be set in tauri setup
pub async fn set(&self, p: Pool<Sqlite>) {
*self.db.write().await = Some(p);
}
}
// recorders
impl Database {
pub async fn add_recorder(&self, room_id: u64) -> Result<RecorderRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let recorder = RecorderRow {
room_id,
created_at: Utc::now().to_rfc3339(),
};
let _ = sqlx::query("INSERT INTO recorders (room_id, created_at) VALUES ($1, $2)")
.bind(room_id as i64)
.bind(&recorder.created_at)
.execute(&lock)
.await?;
Ok(recorder)
}
pub async fn remove_recorder(&self, room_id: u64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("DELETE FROM recorders WHERE room_id = $1")
.bind(room_id as i64)
.execute(&lock)
.await?;
if sql.rows_affected() != 1 {
return Err(DatabaseError::NotFoundError);
}
Ok(())
}
pub async fn get_recorders(&self) -> Result<Vec<RecorderRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, RecorderRow>("SELECT * FROM recorders")
.fetch_all(&lock)
.await?)
}
}
// accounts
impl Database {
// CREATE TABLE accounts (uid INTEGER PRIMARY KEY, name TEXT, avatar TEXT, csrf TEXT, cookies TEXT, created_at TEXT);
pub async fn add_account(&self, cookies: &str) -> Result<AccountRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
// parse cookies
let csrf =
cookies
.split(';')
.map(|cookie| cookie.trim())
.find_map(|cookie| -> Option<String> {
match cookie.starts_with("bili_jct=") {
true => {
let var_name = &"bili_jct=";
Some(cookie[var_name.len()..].to_string())
}
false => None,
}
});
if csrf.is_none() {
return Err(DatabaseError::InvalidCookiesError);
}
// parse uid
let uid = cookies
.split("DedeUserID=")
.collect::<Vec<&str>>()
.get(1)
.unwrap()
.split(";")
.collect::<Vec<&str>>()
.first()
.unwrap()
.to_string()
.parse::<u64>()
.map_err(|_| DatabaseError::InvalidCookiesError)?;
let account = AccountRow {
uid,
name: "".into(),
avatar: "".into(),
csrf: csrf.unwrap(),
cookies: cookies.into(),
created_at: Utc::now().to_rfc3339(),
};
sqlx::query("INSERT INTO accounts (uid, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6)").bind(account.uid as i64).bind(&account.name).bind(&account.avatar).bind(&account.csrf).bind(&account.cookies).bind(&account.created_at).execute(&lock).await?;
Ok(account)
}
pub async fn remove_account(&self, uid: u64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("DELETE FROM accounts WHERE uid = $1")
.bind(uid as i64)
.execute(&lock)
.await?;
if sql.rows_affected() != 1 {
return Err(DatabaseError::NotFoundError);
}
Ok(())
}
pub async fn update_account(
&self,
uid: u64,
name: &str,
avatar: &str,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3")
.bind(name)
.bind(avatar)
.bind(uid as i64)
.execute(&lock)
.await?;
if sql.rows_affected() != 1 {
return Err(DatabaseError::NotFoundError);
}
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")
.fetch_all(&lock)
.await?)
}
pub async fn get_account(&self, uid: u64) -> Result<AccountRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(
sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE uid = $1")
.bind(uid as i64)
.fetch_one(&lock)
.await?,
)
}
}
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct MessageRow {
pub id: i64,
pub title: String,
pub content: String,
pub read: u8,
pub created_at: String,
}
// messages
// CREATE TABLE messages (id INTEGER PRIMARY KEY, title TEXT, content TEXT, read INTEGER, created_at TEXT);
impl Database {
pub async fn new_message(&self, title: &str, content: &str) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query(
"INSERT INTO messages (title, content, read, created_at) VALUES ($1, $2, 0, $3)",
)
.bind(title)
.bind(content)
.bind(Utc::now().to_rfc3339())
.execute(&lock)
.await?;
Ok(())
}
pub async fn read_message(&self, id: i64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE messages SET read = $1 WHERE id = $2")
.bind(1)
.bind(id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn delete_message(&self, id: i64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("DELETE FROM messages WHERE id = $1")
.bind(id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn get_messages(&self) -> Result<Vec<MessageRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, MessageRow>("SELECT * FROM messages;")
.fetch_all(&lock)
.await?)
}
}
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct RecordRow {
pub live_id: u64,
pub room_id: u64,
pub title: String,
pub length: i64,
pub size: i64,
pub created_at: String,
}
// 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> {
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?,
)
}
pub async fn get_record(&self, room_id: u64, live_id: u64) -> 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",
)
.bind(live_id as i64)
.bind(room_id as i64)
.fetch_one(&lock)
.await?)
}
pub async fn add_record(
&self,
live_id: u64,
room_id: u64,
title: &str,
) -> Result<RecordRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let record = RecordRow {
live_id,
room_id,
title: title.into(),
length: 0,
size: 0,
created_at: Utc::now().to_rfc3339(),
};
if let Err(e) = sqlx::query("INSERT INTO records (live_id, room_id, title, length, size, created_at) VALUES ($1, $2, $3, $4, $5, $6)").bind(record.live_id as i64)
.bind(record.room_id as i64).bind(&record.title).bind(0).bind(0).bind(&record.created_at).execute(&lock).await {
// if the record already exists, return the existing record
if e.to_string().contains("UNIQUE constraint failed") {
return self.get_record(room_id, live_id).await;
}
}
Ok(record)
}
pub async fn remove_record(&self, live_id: u64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("DELETE FROM records WHERE live_id = $1")
.bind(live_id as i64)
.execute(&lock)
.await?;
Ok(())
}
pub async fn update_record(
&self,
live_id: u64,
length: i64,
size: u64,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE records SET length = $1, size = $2 WHERE live_id = $3")
.bind(length)
.bind(size as i64)
.bind(live_id as i64)
.execute(&lock)
.await?;
Ok(())
}
}
// CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id INTEGER, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT);
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct VideoRow {
pub id: i64,
pub room_id: u64,
pub cover: String,
pub file: String,
pub length: i64,
pub size: i64,
pub status: i64,
pub bvid: String,
pub title: String,
pub desc: String,
pub tags: String,
pub area: i64,
pub created_at: String,
}
impl Database {
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
.bind(room_id as i64)
.fetch_all(&lock)
.await?,
)
}
pub async fn get_video(&self, id: i64) -> Result<VideoRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
.bind(id)
.fetch_one(&lock)
.await?,
)
}
pub async fn update_video(
&self,
video_id: i64,
status: i64,
bvid: &str,
title: &str,
desc: &str,
tags: &str,
area: u64,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7")
.bind(status)
.bind(bvid)
.bind(title)
.bind(desc)
.bind(tags)
.bind(area as i64)
.bind(video_id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn delete_video(&self, id: i64) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("DELETE FROM videos WHERE id = $1")
.bind(id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn add_video(
&self,
room_id: u64,
cover: &str,
file: &str,
length: i64,
size: i64,
status: i64,
bvid: &str,
title: &str,
desc: &str,
tags: &str,
area: i64,
) -> Result<VideoRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let mut video = VideoRow {
id: 0,
room_id,
cover: cover.into(),
file: file.into(),
length,
size,
status,
bvid: bvid.into(),
title: title.into(),
desc: desc.into(),
tags: tags.into(),
area,
created_at: Utc::now().to_rfc3339(),
};
let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, length, size, status, bvid, title, desc, tags, area, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)")
.bind(video.room_id as i64)
.bind(&video.cover)
.bind(&video.file)
.bind(video.length)
.bind(video.size)
.bind(video.status)
.bind(&video.bvid)
.bind(&video.title)
.bind(&video.desc)
.bind(&video.tags)
.bind(video.area)
.bind(&video.created_at)
.execute(&lock)
.await?;
video.id = sql.last_insert_rowid();
Ok(video)
}
}

48
src-tauri/src/ffmpeg.rs Normal file
View File

@@ -0,0 +1,48 @@
use ffmpeg_sidecar::{
command::FfmpegCommand,
event::{FfmpegEvent, LogLevel},
};
pub struct TranscodeConfig {
pub input_path: String,
pub input_format: String,
pub output_path: String,
}
pub struct TranscodeResult {
pub output_path: String,
}
pub fn transcode(work_dir: &str, config: TranscodeConfig) -> Result<TranscodeResult, String> {
let input_path = config.input_path;
let input_format = config.input_format;
let output_path = config.output_path;
println!("transcode task start: input_path: {}, output_path: {}", input_path, output_path);
FfmpegCommand::new()
.args([
"-f", input_format.as_str(),
])
.input(format!("{}/{}", work_dir, input_path))
.args(["-c", "copy"])
.args(["-y", format!("{}/{}", work_dir, output_path).as_str()])
.spawn()
.unwrap()
.iter()
.unwrap()
.for_each(|e| match e {
FfmpegEvent::Log(LogLevel::Error, e) => println!("Error: {}", e),
FfmpegEvent::Progress(p) => println!("Progress: {}", p.time),
_ => {}
});
println!("transcode task end: output_path: {}", output_path);
Ok(TranscodeResult {
output_path: format!("{}/{}", work_dir, output_path),
})
}

View File

@@ -0,0 +1,92 @@
use crate::database::account::AccountRow;
use crate::recorder::bilibili::client::{QrInfo, QrStatus};
use crate::state::State;
use tauri::State as TauriState;
#[tauri::command]
pub async fn get_accounts(state: TauriState<'_, State>) -> Result<super::AccountInfo, String> {
let config = state.config.read().await.clone();
let account_info = super::AccountInfo {
primary_uid: config.primary_uid,
accounts: state.db.get_accounts().await?,
};
Ok(account_info)
}
#[tauri::command]
pub async fn add_account(state: TauriState<'_, State>, platform: String, cookies: &str) -> Result<AccountRow, String> {
let mut is_primary = false;
if platform == "bilibili" && (state.config.read().await.primary_uid == 0 || state.db.get_accounts().await?.is_empty()) {
is_primary = true;
}
let account = state.db.add_account(&platform, cookies).await?;
if platform == "bilibili" {
if is_primary {
state.config.write().await.webid = state.client.fetch_webid(&account).await?;
state.config.write().await.webid_ts = chrono::Utc::now().timestamp();
state.config.write().await.primary_uid = account.uid;
}
let account_info = state
.client
.get_user_info(&state.config.read().await.webid, &account, account.uid)
.await?;
state
.db
.update_account(
&platform,
account_info.user_id,
&account_info.user_name,
&account_info.user_avatar_url,
)
.await?;
}
Ok(account)
}
#[tauri::command]
pub async fn remove_account(state: TauriState<'_, State>, platform: String, uid: u64) -> Result<(), String> {
if state.db.get_accounts().await?.len() == 1 {
return Err("At least one account is required".into());
}
// logout
if platform == "bilibili" {
let account = state.db.get_account(&platform, uid).await?;
state.client.logout(&account).await?;
}
Ok(state.db.remove_account(&platform, uid).await?)
}
#[tauri::command]
pub async fn get_account_count(state: TauriState<'_, State>) -> Result<u64, String> {
Ok(state.db.get_accounts().await?.len() as u64)
}
#[tauri::command]
pub async fn set_primary(state: TauriState<'_, State>, platform: String, uid: u64) -> Result<(), String> {
if platform == "bilibili" {
if (state.db.get_account(&platform, uid).await).is_ok() {
state.config.write().await.primary_uid = uid;
Ok(())
} else {
Err("Account not exist".into())
}
} else {
Err("Unsupported platform".into())
}
}
#[tauri::command]
pub async fn get_qr_status(state: tauri::State<'_, State>, qrcode_key: &str) -> Result<QrStatus, ()> {
match state.client.get_qr_status(qrcode_key).await {
Ok(qr_status) => Ok(qr_status),
Err(_e) => Err(()),
}
}
#[tauri::command]
pub async fn get_qr(state: tauri::State<'_, State>) -> Result<QrInfo, ()> {
match state.client.get_qr().await {
Ok(qr_info) => Ok(qr_info),
Err(_e) => Err(()),
}
}

View File

@@ -0,0 +1,149 @@
use crate::config::Config;
use crate::state::State;
use tauri::State as TauriState;
#[tauri::command]
pub async fn get_config(state: TauriState<'_, State>) -> Result<Config, ()> {
Ok(state.config.read().await.clone())
}
#[tauri::command]
pub async fn set_cache_path(
state: TauriState<'_, State>,
cache_path: String,
) -> Result<(), String> {
let old_cache_path = state.config.read().await.cache.clone();
if old_cache_path == cache_path {
return Ok(());
}
// TODO only pause recorders
// stop and clear all recorders
state.recorder_manager.stop_all().await;
// first switch to new cache
state.config.write().await.set_cache_path(&cache_path);
log::info!("Cache path changed: {}", cache_path);
// Copy old cache to new cache
log::info!("Start copy old cache to new cache");
state
.db
.new_message(
"缓存目录切换",
"缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用",
)
.await?;
let mut old_cache_entries = vec![];
if let Ok(entries) = std::fs::read_dir(&old_cache_path) {
for entry in entries.flatten() {
// check if entry is the same as new cache path
if entry.path() == std::path::Path::new(&cache_path) {
continue;
}
old_cache_entries.push(entry.path());
}
}
// copy all entries to new cache
for entry in &old_cache_entries {
let new_entry = std::path::Path::new(&cache_path).join(entry.file_name().unwrap());
// if entry is a folder
if entry.is_dir() {
if let Err(e) = crate::handlers::utils::copy_dir_all(entry, &new_entry) {
log::error!("Copy old cache to new cache error: {}", e);
}
} else if let Err(e) = std::fs::copy(entry, &new_entry) {
log::error!("Copy old cache to new cache error: {}", e);
}
}
log::info!("Copy old cache to new cache done");
state.db.new_message("缓存目录切换", "缓存切换完成").await?;
// start all recorders
let primary_account = state
.db
.get_account("bilibili", state.config.read().await.primary_uid)
.await?;
crate::init_rooms(
state.db.clone(),
state.recorder_manager.clone(),
&primary_account,
&state.config.read().await.webid,
)
.await;
// remove all old cache entries
for entry in old_cache_entries {
if entry.is_dir() {
if let Err(e) = std::fs::remove_dir_all(&entry) {
log::error!("Remove old cache error: {}", e);
}
} else if let Err(e) = std::fs::remove_file(&entry) {
log::error!("Remove old cache error: {}", e);
}
}
Ok(())
}
#[tauri::command]
pub async fn set_output_path(state: TauriState<'_, State>, output_path: String) -> Result<(), ()> {
let mut config = state.config.write().await;
let old_output_path = config.output.clone();
if old_output_path == output_path {
return Ok(());
}
// list all file and folder in old output
let mut old_output_entries = vec![];
if let Ok(entries) = std::fs::read_dir(&old_output_path) {
for entry in entries.flatten() {
// check if entry is the same as new output path
if entry.path() == std::path::Path::new(&output_path) {
continue;
}
old_output_entries.push(entry.path());
}
}
// rename all entries to new output
for entry in &old_output_entries {
let new_entry = std::path::Path::new(&output_path).join(entry.file_name().unwrap());
// if entry is a folder
if entry.is_dir() {
if let Err(e) = crate::handlers::utils::copy_dir_all(entry, &new_entry) {
log::error!("Copy old cache to new cache error: {}", e);
}
} else if let Err(e) = std::fs::copy(entry, &new_entry) {
log::error!("Copy old cache to new cache error: {}", e);
}
}
// remove all old output entries
for entry in old_output_entries {
if entry.is_dir() {
if let Err(e) = std::fs::remove_dir_all(&entry) {
log::error!("Remove old cache error: {}", e);
}
} else if let Err(e) = std::fs::remove_file(&entry) {
log::error!("Remove old cache error: {}", e);
}
}
config.set_output_path(&output_path);
Ok(())
}
#[tauri::command]
pub async fn update_notify(
state: TauriState<'_, State>,
live_start_notify: bool,
live_end_notify: bool,
clip_notify: bool,
post_notify: bool,
) -> Result<(), ()> {
state.config.write().await.live_start_notify = live_start_notify;
state.config.write().await.live_end_notify = live_end_notify;
state.config.write().await.clip_notify = clip_notify;
state.config.write().await.post_notify = post_notify;
state.config.write().await.save();
Ok(())
}

View File

@@ -0,0 +1,18 @@
use crate::database::message::MessageRow;
use crate::state::State;
use tauri::State as TauriState;
#[tauri::command]
pub async fn get_messages(state: TauriState<'_, State>) -> Result<Vec<MessageRow>, String> {
Ok(state.db.get_messages().await?)
}
#[tauri::command]
pub async fn read_message(state: TauriState<'_, State>, id: i64) -> Result<(), String> {
Ok(state.db.read_message(id).await?)
}
#[tauri::command]
pub async fn delete_message(state: TauriState<'_, State>, id: i64) -> Result<(), String> {
Ok(state.db.delete_message(id).await?)
}

View File

@@ -0,0 +1,21 @@
pub mod account;
pub mod config;
pub mod message;
pub mod recorder;
pub mod utils;
pub mod video;
use crate::database::account::AccountRow;
#[derive(serde::Serialize)]
pub struct AccountInfo {
pub primary_uid: u64,
pub accounts: Vec<AccountRow>,
}
#[derive(serde::Serialize)]
pub struct DiskInfo {
pub disk: String,
pub total: u64,
pub free: u64,
}

View File

@@ -0,0 +1,214 @@
use crate::database::record::RecordRow;
use crate::database::recorder::RecorderRow;
use crate::recorder::danmu::DanmuEntry;
use crate::recorder::PlatformType;
use crate::recorder::RecorderInfo;
use crate::recorder_manager::RecorderList;
use crate::state::State;
use tauri::State as TauriState;
#[tauri::command]
pub async fn get_recorder_list(state: TauriState<'_, State>) -> Result<RecorderList, ()> {
Ok(state.recorder_manager.get_recorder_list().await)
}
#[tauri::command]
pub async fn add_recorder(
state: TauriState<'_, State>,
platform: String,
room_id: u64,
) -> Result<RecorderRow, String> {
log::info!("Add recorder: {} {}", platform, room_id);
let platform = PlatformType::from_str(&platform).unwrap();
let account = match platform {
PlatformType::BiliBili => {
if let Ok(account) = state
.db
.get_account("bilibili", state.config.read().await.primary_uid)
.await
{
if state.config.read().await.webid_expired() {
log::info!("Webid expired, refetching");
state.config.write().await.webid = state.client.fetch_webid(&account).await?;
state.config.write().await.webid_ts = chrono::Utc::now().timestamp();
}
Ok(account)
} else {
Err("没有可用账号,请先添加账号".to_string())
}
}
PlatformType::Douyin => {
if let Ok(account) = state.db.get_account_by_platform("douyin").await {
Ok(account)
} else {
Err("没有可用账号,请先添加账号".to_string())
}
}
_ => Err("不支持的平台".to_string()),
};
match account {
Ok(account) => match state
.recorder_manager
.add_recorder(
state.config.read().await.webid.as_str(),
&account,
platform,
room_id,
)
.await
{
Ok(()) => {
let room = state.db.add_recorder(platform, room_id).await?;
state
.db
.new_message("添加直播间", &format!("添加了新直播间 {}", room_id))
.await?;
Ok(room)
}
Err(e) => Err(format!("添加失败: {}", e)),
},
Err(e) => Err(format!("添加失败: {}", e)),
}
}
#[tauri::command]
pub async fn remove_recorder(
state: TauriState<'_, State>,
platform: String,
room_id: u64,
) -> Result<(), String> {
let platform = PlatformType::from_str(&platform).unwrap();
match state
.recorder_manager
.remove_recorder(platform, room_id)
.await
{
Ok(()) => {
state
.db
.new_message("移除直播间", &format!("移除了直播间 {}", room_id))
.await?;
Ok(state.db.remove_recorder(room_id).await?)
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
pub async fn get_room_info(
state: TauriState<'_, State>,
platform: String,
room_id: u64,
) -> Result<RecorderInfo, String> {
let platform = PlatformType::from_str(&platform).unwrap();
if let Some(info) = state
.recorder_manager
.get_recorder_info(platform, room_id)
.await
{
Ok(info)
} else {
Err("Not found".to_string())
}
}
#[tauri::command]
pub async fn get_archives(
state: TauriState<'_, State>,
room_id: u64,
) -> Result<Vec<RecordRow>, String> {
log::debug!("Get archives for {}", room_id);
Ok(state.recorder_manager.get_archives(room_id).await?)
}
#[tauri::command]
pub async fn get_archive(
state: TauriState<'_, State>,
room_id: u64,
live_id: String,
) -> Result<RecordRow, String> {
Ok(state
.recorder_manager
.get_archive(room_id, &live_id)
.await?)
}
#[tauri::command]
pub async fn delete_archive(
state: TauriState<'_, State>,
platform: String,
room_id: u64,
live_id: String,
) -> Result<(), String> {
let platform = PlatformType::from_str(&platform).unwrap();
state
.recorder_manager
.delete_archive(platform, room_id, &live_id)
.await?;
state
.db
.new_message(
"删除历史缓存",
&format!("删除了房间 {} 的历史缓存 {}", room_id, live_id),
)
.await?;
Ok(())
}
#[tauri::command]
pub async fn get_danmu_record(
state: TauriState<'_, State>,
platform: String,
room_id: u64,
live_id: String,
) -> Result<Vec<DanmuEntry>, String> {
let platform = PlatformType::from_str(&platform).unwrap();
Ok(state
.recorder_manager
.get_danmu(platform, room_id, &live_id)
.await?)
}
#[tauri::command]
pub async fn send_danmaku(
state: TauriState<'_, State>,
uid: u64,
room_id: u64,
message: String,
) -> Result<(), String> {
let account = state.db.get_account("bilibili", uid).await?;
state
.client
.send_danmaku(&account, room_id, &message)
.await?;
Ok(())
}
#[tauri::command]
pub async fn get_total_length(state: TauriState<'_, State>) -> Result<i64, String> {
match state.db.get_total_length().await {
Ok(total_length) => Ok(total_length),
Err(e) => Err(format!("Failed to get total length: {}", e)),
}
}
#[tauri::command]
pub async fn get_today_record_count(state: TauriState<'_, State>) -> Result<i64, String> {
match state.db.get_today_record_count().await {
Ok(count) => Ok(count),
Err(e) => Err(format!("Failed to get today record count: {}", e)),
}
}
#[tauri::command]
pub async fn get_recent_record(
state: TauriState<'_, State>,
offset: u64,
limit: u64,
) -> Result<Vec<RecordRow>, String> {
match state.db.get_recent_record(offset, limit).await {
Ok(records) => Ok(records),
Err(e) => Err(format!("Failed to get recent record: {}", e)),
}
}

View File

@@ -0,0 +1,195 @@
use std::process::Command;
use tauri::Theme;
use tauri_utils::config::WindowEffectsConfig;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use crate::recorder::PlatformType;
use crate::state::State;
pub fn copy_dir_all(
src: impl AsRef<std::path::Path>,
dst: impl AsRef<std::path::Path>,
) -> std::io::Result<()> {
std::fs::create_dir_all(&dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
} else {
std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}
#[tauri::command]
pub fn show_in_folder(path: String) {
#[cfg(target_os = "windows")]
{
Command::new("explorer")
.args(["/select,", &path]) // The comma after select is not a typo
.spawn()
.unwrap();
}
#[cfg(target_os = "linux")]
{
use std::fs::metadata;
use std::path::PathBuf;
if path.contains(",") {
// see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76
let new_path = match metadata(&path).unwrap().is_dir() {
true => path,
false => {
let mut path2 = PathBuf::from(path);
path2.pop();
path2.into_os_string().into_string().unwrap()
}
};
Command::new("xdg-open").arg(&new_path).spawn().unwrap();
} else {
Command::new("dbus-send")
.args([
"--session",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
format!("array:string:\"file://{path}\"").as_str(),
"string:\"\"",
])
.spawn()
.unwrap();
}
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.args(["-R", &path])
.spawn()
.unwrap()
.wait()
.unwrap();
}
}
#[derive(serde::Serialize)]
pub struct DiskInfo {
disk: String,
total: u64,
free: u64,
}
#[tauri::command]
pub async fn get_disk_info(state: tauri::State<'_, State>) -> Result<DiskInfo, ()> {
let cache = state.config.read().await.cache.clone();
// check system disk info
let disks = sysinfo::Disks::new_with_refreshed_list();
// get cache disk info
let mut disk_info = DiskInfo {
disk: "".into(),
total: 0,
free: 0,
};
for disk in disks.list() {
// if output is under disk mount point
if cache.starts_with(disk.mount_point().to_str().unwrap()) {
// if MacOS, using disk name
#[cfg(target_os = "macos")]
{
disk_info.disk = disk.name().to_str().unwrap().into();
}
// if Windows, using disk mount point
#[cfg(target_os = "windows")]
{
disk_info.disk = disk.mount_point().to_str().unwrap().into();
}
disk_info.total = disk.total_space();
disk_info.free = disk.available_space();
break;
}
}
Ok(disk_info)
}
#[tauri::command]
pub async fn export_to_file(
_state: tauri::State<'_, State>,
file_name: &str,
content: &str,
) -> Result<(), String> {
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(file_name)
.await;
if file.is_err() {
return Err(format!("Open file failed: {}", file.err().unwrap()));
}
let mut file = file.unwrap();
if let Err(e) = file.write_all(content.as_bytes()).await {
return Err(format!("Write file failed: {}", e));
}
if let Err(e) = file.flush().await {
return Err(format!("Flush file failed: {}", e));
}
Ok(())
}
#[tauri::command]
pub async fn open_live(
state: tauri::State<'_, State>,
platform: String,
room_id: u64,
live_id: String,
) -> Result<(), String> {
log::info!("Open player window: {} {}", room_id, live_id);
let addr = state.recorder_manager.get_hls_server_addr().await.unwrap();
let platform = PlatformType::from_str(&platform).unwrap();
let recorder_info = state
.recorder_manager
.get_recorder_info(platform, room_id)
.await
.unwrap();
let handle = state.app_handle.clone();
let builder = tauri::WebviewWindowBuilder::new(
&handle,
format!("Live:{}:{}", room_id, live_id),
tauri::WebviewUrl::App(
format!(
"live_index.html?port={}&platform={}&room_id={}&live_id={}",
addr.port(),
platform.as_str(),
room_id,
live_id
)
.into(),
),
)
.title(format!(
"Live[{}] {}",
room_id, recorder_info.room_info.room_title
))
.theme(Some(Theme::Light))
.inner_size(1200.0, 800.0)
.effects(WindowEffectsConfig {
effects: vec![
tauri_utils::WindowEffect::Tabbed,
tauri_utils::WindowEffect::Mica,
],
state: None,
radius: None,
color: None,
});
if let Err(e) = builder.decorations(true).build() {
log::error!("live window build failed: {}", e);
}
Ok(())
}

View File

@@ -0,0 +1,176 @@
use crate::database::video::VideoRow;
use crate::recorder::bilibili::profile::Profile;
use crate::recorder::PlatformType;
use crate::state::State;
use chrono::Utc;
use std::path::Path;
use tauri::State as TauriState;
use tauri_plugin_notification::NotificationExt;
#[tauri::command]
pub async fn clip_range(
state: TauriState<'_, State>,
cover: String,
platform: String,
room_id: u64,
live_id: String,
x: f64,
y: f64,
) -> Result<VideoRow, String> {
log::info!(
"Clip room_id: {}, ts: {}, start: {}, end: {}",
room_id,
live_id,
x,
y
);
let platform = PlatformType::from_str(&platform).unwrap();
let file = state
.recorder_manager
.clip_range(&state.config.read().await.output, platform, room_id, &live_id, x, y)
.await?;
// get file metadata from fs
let metadata = std::fs::metadata(&file).map_err(|e| e.to_string())?;
// get filename from path
let filename = Path::new(&file)
.file_name()
.ok_or("Invalid file path")?
.to_str()
.ok_or("Invalid file path")?;
// add video to db
let video = state
.db
.add_video(&VideoRow {
id: 0,
status: 0,
room_id,
created_at: Utc::now().to_rfc3339(),
cover: cover.clone(),
file: filename.into(),
length: (y - x) as i64,
size: metadata.len() as i64,
bvid: "".into(),
title: "".into(),
desc: "".into(),
tags: "".into(),
area: 0,
})
.await?;
state
.db
.new_message(
"生成新切片",
&format!(
"生成了房间 {} 的切片,长度 {:.1}s{}",
room_id,
y - x,
filename
),
)
.await?;
if state.config.read().await.clip_notify {
state
.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 切片完成")
.body(format!("生成了房间 {} 的切片: {}", room_id, filename))
.show()
.unwrap();
}
Ok(video)
}
#[tauri::command]
pub async fn upload_procedure(
state: TauriState<'_, State>,
uid: u64,
room_id: u64,
video_id: i64,
cover: String,
mut profile: Profile,
) -> Result<String, String> {
let account = state.db.get_account("bilibili", uid).await?;
// get video info from dbs
let mut video_row = state.db.get_video(video_id).await?;
// construct file path
let output = state.config.read().await.output.clone();
let file = format!("{}/{}", output, video_row.file);
let path = Path::new(&file);
let cover_url = state.client.upload_cover(&account, &cover);
if let Ok(video) = state.client.prepare_video(&account, path).await {
profile.cover = cover_url.await.unwrap_or("".to_string());
if let Ok(ret) = state.client.submit_video(&account, &profile, &video).await {
// update video status and details
// 1 means uploaded
video_row.status = 1;
video_row.bvid = ret.bvid.clone();
video_row.title = profile.title;
video_row.desc = profile.desc;
video_row.tags = profile.tag;
video_row.area = profile.tid as i64;
state.db.update_video(&video_row).await?;
state
.db
.new_message(
"投稿成功",
&format!("投稿了房间 {} 的切片:{}", room_id, ret.bvid),
)
.await?;
if state.config.read().await.post_notify {
state
.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 投稿成功")
.body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid))
.show()
.unwrap();
}
Ok(ret.bvid)
} else {
Err("Submit video failed".to_string())
}
} else {
Err("Preload video failed".to_string())
}
}
#[tauri::command]
pub async fn get_video(state: TauriState<'_, State>, id: i64) -> Result<VideoRow, String> {
Ok(state.db.get_video(id).await?)
}
#[tauri::command]
pub async fn get_videos(state: TauriState<'_, State>, room_id: u64) -> Result<Vec<VideoRow>, String> {
Ok(state.db.get_videos(room_id).await?)
}
#[tauri::command]
pub async fn delete_video(state: TauriState<'_, State>, id: i64) -> Result<(), String> {
// get video info from dbus
let video = state.db.get_video(id).await?;
// delete video files
let filepath = format!("{}/{}", state.config.read().await.output, video.file);
let file = Path::new(&filepath);
if let Err(e) = std::fs::remove_file(file) {
log::error!("Delete video file error: {}", e);
}
Ok(state.db.delete_video(id).await?)
}
#[tauri::command]
pub async fn get_video_typelist(
state: TauriState<'_, State>,
) -> Result<Vec<crate::recorder::bilibili::response::Typelist>, String> {
let account = state
.db
.get_account("bilibili", state.config.read().await.primary_uid)
.await?;
Ok(state.client.get_video_typelist(&account).await?)
}
#[tauri::command]
pub async fn update_video_cover(state: TauriState<'_, State>, id: i64, cover: String) -> Result<(), String> {
Ok(state.db.update_video_cover(id, cover).await?)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,798 @@
use super::errors::BiliClientError;
use super::profile;
use super::profile::Profile;
use super::response;
use super::response::Format;
use super::response::GeneralResponse;
use super::response::PostVideoMetaResponse;
use super::response::PreuploadResponse;
use super::response::VideoSubmitData;
use crate::database::account::AccountRow;
use base64::Engine;
use pct_str::PctString;
use pct_str::URIReserved;
use regex::Regex;
use reqwest::Client;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use serde_json::Value;
use std::fmt;
use std::path::Path;
use std::time::Duration;
use std::time::SystemTime;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio::time::Instant;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RoomInfo {
pub live_status: u8,
pub room_cover_url: String,
pub room_id: u64,
pub room_keyframe_url: String,
pub room_title: String,
pub user_id: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserInfo {
pub user_id: u64,
pub user_name: String,
pub user_sign: String,
pub user_avatar_url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QrInfo {
pub oauth_key: String,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QrStatus {
pub code: u8,
pub cookies: String,
}
/// BiliClient is thread safe
pub struct BiliClient {
client: Client,
headers: reqwest::header::HeaderMap,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StreamType {
TS,
FMP4,
}
#[derive(Clone, Debug)]
pub struct BiliStream {
pub format: StreamType,
pub host: String,
pub path: String,
pub extra: String,
pub expire: i64,
}
impl fmt::Display for BiliStream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"type: {:?}, host: {}, path: {}, extra: {}, expire: {}",
self.format, self.host, self.path, self.extra, self.expire
)
}
}
impl BiliStream {
pub fn new(format: StreamType, base_url: &str, host: &str, extra: &str) -> BiliStream {
BiliStream {
format,
host: host.into(),
path: BiliStream::get_path(base_url),
extra: extra.into(),
expire: BiliStream::get_expire(extra).unwrap_or(600000),
}
}
pub fn index(&self) -> String {
format!("{}{}{}?{}", self.host, self.path, "index.m3u8", self.extra)
}
pub fn ts_url(&self, seg_name: &str) -> String {
format!("{}{}{}?{}", self.host, self.path, seg_name, self.extra)
}
pub fn get_path(base_url: &str) -> String {
match base_url.rfind('/') {
Some(pos) => base_url[..pos + 1].to_string(),
None => base_url.to_string(),
}
}
pub fn get_expire(extra: &str) -> Option<i64> {
extra.split('&').find_map(|param| {
if param.starts_with("expires=") {
param.split('=').nth(1)?.parse().ok()
} else {
None
}
})
}
}
impl BiliClient {
pub fn new() -> 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());
if let Ok(client) = Client::builder().timeout(Duration::from_secs(10)).build() {
Ok(BiliClient { client, headers })
} else {
Err(BiliClientError::InitClientError)
}
}
pub async fn fetch_webid(&self, account: &AccountRow) -> Result<String, BiliClientError> {
// get webid from html content
// webid is in script tag <script id="__RENDER_DATA__" type="application/json">
// https://space.bilibili.com/{user_id}
let url = format!("https://space.bilibili.com/{}", account.uid);
let res = self.client.get(&url).send().await?;
let content = res.text().await?;
let re =
Regex::new(r#"<script id="__RENDER_DATA__" type="application/json">(.+?)</script>"#)
.unwrap();
let cap = re.captures(&content).ok_or(BiliClientError::InvalidValue)?;
let str = cap.get(1).ok_or(BiliClientError::InvalidValue)?.as_str();
// str need url decode
let json_str = urlencoding::decode(str).map_err(|_| BiliClientError::InvalidValue)?; // url decode
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let webid = json["access_id"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?;
log::info!("webid: {}", webid);
Ok(webid.into())
}
pub async fn get_qr(&self) -> Result<QrInfo, BiliClientError> {
let res: serde_json::Value = self
.client
.get("https://passport.bilibili.com/x/passport-login/web/qrcode/generate")
.headers(self.headers.clone())
.send()
.await?
.json()
.await?;
Ok(QrInfo {
oauth_key: res["data"]["qrcode_key"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string(),
url: res["data"]["url"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string(),
})
}
pub async fn get_qr_status(&self, qrcode_key: &str) -> Result<QrStatus, BiliClientError> {
let res: serde_json::Value = self
.client
.get(format!(
"https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key={}",
qrcode_key
))
.headers(self.headers.clone())
.send()
.await?
.json()
.await?;
let code: u8 = res["data"]["code"].as_u64().unwrap_or(400) as u8;
let mut cookies: String = "".to_string();
if code == 0 {
let url = res["data"]["url"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string();
let query_str = url.split('?').last().unwrap();
cookies = query_str.replace('&', ";");
}
Ok(QrStatus { code, cookies })
}
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());
let params = [("csrf", account.csrf.clone())];
let _ = self
.client
.post(url)
.headers(headers)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.send()
.await?;
Ok(())
}
pub async fn get_user_info(
&self,
webid: &str,
account: &AccountRow,
user_id: u64,
) -> Result<UserInfo, BiliClientError> {
let params: Value = json!({
"mid": user_id.to_string(),
"platform": "web",
"web_location": "1550101",
"token": "",
"w_webid": webid,
});
let params = self.get_sign(params).await?;
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let res: serde_json::Value = self
.client
.get(format!(
"https://api.bilibili.com/x/space/wbi/acc/info?{}",
params
))
.headers(headers)
.send()
.await?
.json()
.await?;
if res["code"].as_i64().unwrap_or(-1) != 0 {
log::error!(
"Get user info failed {}",
res["code"].as_i64().unwrap_or(-1)
);
return Err(BiliClientError::InvalidCode);
}
Ok(UserInfo {
user_id,
user_name: res["data"]["name"].as_str().unwrap_or("").to_string(),
user_sign: res["data"]["sign"].as_str().unwrap_or("").to_string(),
user_avatar_url: res["data"]["face"].as_str().unwrap_or("").to_string(),
})
}
pub async fn get_room_info(
&self,
account: &AccountRow,
room_id: u64,
) -> Result<RoomInfo, BiliClientError> {
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let res: serde_json::Value = self
.client
.get(format!(
"https://api.live.bilibili.com/room/v1/Room/get_info?room_id={}",
room_id
))
.headers(headers)
.send()
.await?
.json()
.await?;
let code = res["code"].as_u64().ok_or(BiliClientError::InvalidValue)?;
if code != 0 {
return Err(BiliClientError::InvalidCode);
}
let room_id = res["data"]["room_id"]
.as_u64()
.ok_or(BiliClientError::InvalidValue)?;
let room_title = res["data"]["title"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string();
let room_cover_url = res["data"]["user_cover"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string();
let room_keyframe_url = res["data"]["keyframe"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string();
let user_id = res["data"]["uid"]
.as_u64()
.ok_or(BiliClientError::InvalidValue)?;
let live_status = res["data"]["live_status"]
.as_u64()
.ok_or(BiliClientError::InvalidValue)? as u8;
Ok(RoomInfo {
room_id,
room_title,
room_cover_url,
room_keyframe_url,
user_id,
live_status,
})
}
pub async fn get_cover_base64(&self, url: &str) -> Result<String, BiliClientError> {
log::info!("get_cover_base64: {}", url);
let response = self.client.get(url).send().await?;
let bytes = response.bytes().await?;
let base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let mime_type = mime_guess::from_path(url)
.first_or_octet_stream()
.to_string();
Ok(format!("data:{};base64,{}", mime_type, base64))
}
pub async fn get_play_url(
&self,
account: &AccountRow,
room_id: u64,
) -> Result<BiliStream, BiliClientError> {
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let res: GeneralResponse = self
.client
.get(format!(
"https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id={}&protocol=1&format=0,1,2&codec=0&qn=10000&platform=h5",
room_id
))
.headers(headers)
.send().await?
.json().await?;
if res.code == 0 {
if let response::Data::RoomPlayInfo(data) = res.data {
if let Some(stream) = data.playurl_info.playurl.stream.first() {
// Get fmp4 format
if let Some(f) = stream.format.iter().find(|f| f.format_name == "fmp4") {
self.get_stream(f).await
} else {
log::error!("No fmp4 stream found: {:#?}", data);
Err(BiliClientError::InvalidResponse)
}
} else {
log::error!("No stream provided: {:#?}", data);
Err(BiliClientError::InvalidResponse)
}
} else {
log::error!("Invalid response: {:#?}", res);
Err(BiliClientError::InvalidResponse)
}
} else {
log::error!("Invalid response: {:#?}", res);
Err(BiliClientError::InvalidResponse)
}
}
async fn get_stream(&self, format: &Format) -> Result<BiliStream, BiliClientError> {
if let Some(codec) = format.codec.first() {
if let Some(url_info) = codec.url_info.first() {
Ok(BiliStream::new(
StreamType::FMP4,
&codec.base_url,
&url_info.host,
&url_info.extra,
))
} else {
Err(BiliClientError::InvalidFormat)
}
} else {
Err(BiliClientError::InvalidFormat)
}
}
pub async fn get_index_content(&self, url: &String) -> Result<String, BiliClientError> {
Ok(self
.client
.get(url.to_owned())
.headers(self.headers.clone())
.send()
.await?
.text()
.await?)
}
pub async fn download_ts(&self, url: &str, file_path: &str) -> Result<u64, BiliClientError> {
let res = self
.client
.get(url)
.headers(self.headers.clone())
.send()
.await?;
let mut file = std::fs::File::create(file_path)?;
let bytes = res.bytes().await?;
let size = bytes.len() as u64;
let mut content = std::io::Cursor::new(bytes);
std::io::copy(&mut content, &mut file)?;
Ok(size)
}
// Method from js code
pub async fn get_sign(&self, mut parameters: Value) -> Result<String, BiliClientError> {
let table = vec![
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42,
19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60,
51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
];
let nav_info: Value = self
.client
.get("https://api.bilibili.com/x/web-interface/nav")
.headers(self.headers.clone())
.send()
.await?
.json()
.await?;
let re = Regex::new(r"wbi/(.*).png").unwrap();
let img = re
.captures(nav_info["data"]["wbi_img"]["img_url"].as_str().unwrap())
.unwrap()
.get(1)
.unwrap()
.as_str();
let sub = re
.captures(nav_info["data"]["wbi_img"]["sub_url"].as_str().unwrap())
.unwrap()
.get(1)
.unwrap()
.as_str();
let raw_string = format!("{}{}", img, sub);
let mut encoded = Vec::new();
table.into_iter().for_each(|x| {
if x < raw_string.len() {
encoded.push(raw_string.as_bytes()[x]);
}
});
// only keep 32 bytes of encoded
encoded = encoded[0..32].to_vec();
let encoded = String::from_utf8(encoded).unwrap();
// Timestamp in seconds
let wts = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
parameters
.as_object_mut()
.unwrap()
.insert("wts".to_owned(), serde_json::Value::String(wts.to_string()));
// Get all keys from parameters into vec
let mut keys = parameters
.as_object()
.unwrap()
.keys()
.map(|x| x.to_owned())
.collect::<Vec<String>>();
// sort keys
keys.sort();
let mut params = String::new();
keys.iter().for_each(|x| {
params.push_str(x);
params.push('=');
// Value filters !'()* characters
let value = parameters
.get(x)
.unwrap()
.as_str()
.unwrap()
.replace(['!', '\'', '(', ')', '*'], "");
let value = PctString::encode(value.chars(), URIReserved);
params.push_str(value.as_str());
// add & if not last
if x != keys.last().unwrap() {
params.push('&');
}
});
// md5 params+encoded
let w_rid = md5::compute(params.to_string() + encoded.as_str());
let params = params + format!("&w_rid={:x}", w_rid).as_str();
Ok(params)
}
async fn preupload_video(
&self,
account: &AccountRow,
video_file: &Path,
) -> Result<PreuploadResponse, BiliClientError> {
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let url = format!(
"https://member.bilibili.com/preupload?name={}&r=upos&profile=ugcfx/bup",
video_file.file_name().unwrap().to_str().unwrap()
);
let response = self
.client
.get(&url)
.headers(headers)
.send()
.await?
.json::<PreuploadResponse>()
.await?;
Ok(response)
}
async fn post_video_meta(
&self,
preupload_response: &PreuploadResponse,
video_file: &Path,
) -> Result<PostVideoMetaResponse, BiliClientError> {
let url = format!(
"https:{}{}?uploads=&output=json&profile=ugcfx/bup&filesize={}&partsize={}&biz_id={}",
preupload_response.endpoint,
preupload_response.upos_uri.replace("upos:/", ""),
video_file.metadata().unwrap().len(),
preupload_response.chunk_size,
preupload_response.biz_id
);
let response = self
.client
.post(&url)
.header("X-Upos-Auth", &preupload_response.auth)
.send()
.await?
.json::<PostVideoMetaResponse>()
.await?;
Ok(response)
}
async fn upload_video(
&self,
preupload_response: &PreuploadResponse,
post_video_meta_response: &PostVideoMetaResponse,
video_file: &Path,
) -> Result<usize, BiliClientError> {
let mut file = File::open(video_file).await?;
let mut buffer = vec![0; preupload_response.chunk_size];
let file_size = video_file.metadata()?.len();
let chunk_size = preupload_response.chunk_size as u64; // 确保使用 u64 类型
let total_chunks = (file_size as f64 / chunk_size as f64).ceil() as usize; // 计算总分块数
let start = Instant::now();
let mut chunk = 0;
let mut read_total = 0;
while let Ok(size) = file.read(&mut buffer[read_total..]).await {
read_total += size;
log::debug!("size: {}, total: {}", size, read_total);
if size > 0 && (read_total as u64) < chunk_size {
continue;
}
if size == 0 && read_total == 0 {
break;
}
let url = format!(
"https:{}{}?partNumber={}&uploadId={}&chunk={}&chunks={}&size={}&start={}&end={}&total={}",
preupload_response.endpoint,
preupload_response.upos_uri.replace("upos:/", ""),
chunk + 1,
post_video_meta_response.upload_id,
chunk,
total_chunks,
read_total,
chunk * preupload_response.chunk_size,
chunk * preupload_response.chunk_size + read_total,
video_file.metadata().unwrap().len()
);
self.client
.put(&url)
.header("X-Upos-Auth", &preupload_response.auth)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", read_total.to_string())
.body(buffer[..read_total].to_vec())
.send()
.await?
.text()
.await?;
chunk += 1;
read_total = 0;
log::debug!(
"[bili]speed: {:.1} KiB/s",
(chunk * preupload_response.chunk_size) as f64
/ start.elapsed().as_secs_f64()
/ 1024.0
);
}
Ok(total_chunks)
}
async fn end_upload(
&self,
preupload_response: &PreuploadResponse,
post_video_meta_response: &PostVideoMetaResponse,
chunks: usize,
) -> Result<(), BiliClientError> {
let url = format!(
"https:{}{}?output=json&name={}&profile=ugcfx/bup&uploadId={}&biz_id={}",
preupload_response.endpoint,
preupload_response.upos_uri.replace("upos:/", ""),
preupload_response.upos_uri,
post_video_meta_response.upload_id,
preupload_response.biz_id
);
let parts: Vec<Value> = (1..=chunks)
.map(|i| json!({ "partNumber": i, "eTag": "etag" }))
.collect();
let body = json!({ "parts": parts });
self.client
.post(&url)
.header("X-Upos-Auth", &preupload_response.auth)
.header("Content-Type", "application/json; charset=UTF-8")
.body(body.to_string())
.send()
.await?
.text()
.await?;
Ok(())
}
pub async fn prepare_video(
&self,
account: &AccountRow,
video_file: &Path,
) -> Result<profile::Video, BiliClientError> {
log::info!("Start Preparing Video: {}", video_file.to_str().unwrap());
let preupload = self.preupload_video(account, video_file).await?;
log::info!("Preupload Response: {:?}", preupload);
let metaposted = self.post_video_meta(&preupload, video_file).await?;
log::info!("Post Video Meta Response: {:?}", metaposted);
let uploaded = self
.upload_video(&preupload, &metaposted, video_file)
.await?;
log::info!("Uploaded: {}", uploaded);
self.end_upload(&preupload, &metaposted, uploaded).await?;
let filename = Path::new(&metaposted.key)
.file_stem()
.unwrap()
.to_str()
.unwrap();
Ok(profile::Video {
title: "".to_string(),
filename: filename.to_string(),
desc: "".to_string(),
cid: preupload.biz_id,
})
}
pub async fn submit_video(
&self,
account: &AccountRow,
profile_template: &Profile,
video: &profile::Video,
) -> Result<VideoSubmitData, BiliClientError> {
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let url = format!(
"https://member.bilibili.com/x/vu/web/add/v3?ts={}&csrf={}",
chrono::Local::now().timestamp(),
account.csrf
);
let mut preprofile = profile_template.clone();
preprofile.videos.push(video.clone());
match self
.client
.post(&url)
.headers(headers)
.header("Content-Type", "application/json; charset=UTF-8")
.body(serde_json::ser::to_string(&preprofile).unwrap_or("".to_string()))
.send()
.await
{
Ok(raw_resp) => {
let json = raw_resp.json().await?;
if let Ok(resp) = serde_json::from_value::<GeneralResponse>(json) {
match resp.data {
response::Data::VideoSubmit(data) => Ok(data),
_ => Err(BiliClientError::InvalidResponse),
}
} else {
println!("Parse response failed");
Err(BiliClientError::InvalidResponse)
}
}
Err(e) => {
println!("Send failed {}", e);
Err(BiliClientError::InvalidResponse)
}
}
}
pub async fn upload_cover(
&self,
account: &AccountRow,
cover: &str,
) -> Result<String, BiliClientError> {
let url = format!(
"https://member.bilibili.com/x/vu/web/cover/up?ts={}",
chrono::Local::now().timestamp(),
);
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let params = [("csrf", account.csrf.clone()), ("cover", cover.to_string())];
match self
.client
.post(&url)
.headers(headers)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.send()
.await
{
Ok(raw_resp) => {
let json = raw_resp.json().await?;
if let Ok(resp) = serde_json::from_value::<GeneralResponse>(json) {
match resp.data {
response::Data::Cover(data) => Ok(data.url),
_ => Err(BiliClientError::InvalidResponse),
}
} else {
println!("Parse response failed");
Err(BiliClientError::InvalidResponse)
}
}
Err(e) => {
println!("Send failed {}", e);
Err(BiliClientError::InvalidResponse)
}
}
}
pub async fn send_danmaku(
&self,
account: &AccountRow,
room_id: u64,
message: &str,
) -> 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());
let params = [
("bubble", "0"),
("msg", message),
("color", "16777215"),
("mode", "1"),
("fontsize", "25"),
("room_type", "0"),
("rnd", &format!("{}", chrono::Local::now().timestamp())),
("roomid", &format!("{}", room_id)),
("csrf", &account.csrf),
("csrf_token", &account.csrf),
];
let _ = self
.client
.post(&url)
.headers(headers)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.send()
.await?;
Ok(())
}
pub async fn get_video_typelist(
&self,
account: &AccountRow,
) -> 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());
let resp: GeneralResponse = self
.client
.get(url)
.headers(headers)
.send()
.await?
.json()
.await?;
if resp.code == 0 {
if let response::Data::VideoTypeList(data) = resp.data {
Ok(data.typelist)
} else {
Err(BiliClientError::InvalidResponse)
}
} else {
log::error!("Get video typelist failed with code {}", resp.code);
Err(BiliClientError::InvalidResponse)
}
}
}

View File

@@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
#[derive(Serialize, Deserialize, Debug)]
pub struct GeneralResponse {
@@ -13,6 +15,8 @@ pub struct GeneralResponse {
pub enum Data {
VideoSubmit(VideoSubmitData),
Cover(CoverData),
RoomPlayInfo(RoomPlayInfoData),
VideoTypeList(VideoTypeListData),
}
#[derive(Serialize, Deserialize, Debug)]
@@ -41,3 +45,183 @@ pub struct PostVideoMetaResponse {
pub key: String,
pub upload_id: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoomPlayInfoData {
#[serde(rename = "room_id")]
pub room_id: i64,
#[serde(rename = "short_id")]
pub short_id: i64,
pub uid: i64,
#[serde(rename = "is_hidden")]
pub is_hidden: bool,
#[serde(rename = "is_locked")]
pub is_locked: bool,
#[serde(rename = "is_portrait")]
pub is_portrait: bool,
#[serde(rename = "live_status")]
pub live_status: i64,
#[serde(rename = "hidden_till")]
pub hidden_till: i64,
#[serde(rename = "lock_till")]
pub lock_till: i64,
pub encrypted: bool,
#[serde(rename = "pwd_verified")]
pub pwd_verified: bool,
#[serde(rename = "live_time")]
pub live_time: i64,
#[serde(rename = "room_shield")]
pub room_shield: i64,
#[serde(rename = "all_special_types")]
pub all_special_types: Vec<i64>,
#[serde(rename = "playurl_info")]
pub playurl_info: PlayurlInfo,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayurlInfo {
#[serde(rename = "conf_json")]
pub conf_json: String,
pub playurl: Playurl,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Playurl {
pub cid: i64,
#[serde(rename = "g_qn_desc")]
pub g_qn_desc: Vec<GQnDesc>,
pub stream: Vec<Stream>,
#[serde(rename = "p2p_data")]
pub p2p_data: P2pData,
#[serde(rename = "dolby_qn")]
pub dolby_qn: Value,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GQnDesc {
pub qn: i64,
pub desc: String,
#[serde(rename = "hdr_desc")]
pub hdr_desc: String,
#[serde(rename = "attr_desc")]
pub attr_desc: Value,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Stream {
#[serde(rename = "protocol_name")]
pub protocol_name: String,
pub format: Vec<Format>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Format {
#[serde(rename = "format_name")]
pub format_name: String,
pub codec: Vec<Codec>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Codec {
#[serde(rename = "codec_name")]
pub codec_name: String,
#[serde(rename = "current_qn")]
pub current_qn: i64,
#[serde(rename = "accept_qn")]
pub accept_qn: Vec<i64>,
#[serde(rename = "base_url")]
pub base_url: String,
#[serde(rename = "url_info")]
pub url_info: Vec<UrlInfo>,
#[serde(rename = "hdr_qn")]
pub hdr_qn: Value,
#[serde(rename = "dolby_type")]
pub dolby_type: i64,
#[serde(rename = "attr_name")]
pub attr_name: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UrlInfo {
pub host: String,
pub extra: String,
#[serde(rename = "stream_ttl")]
pub stream_ttl: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct P2pData {
pub p2p: bool,
#[serde(rename = "p2p_type")]
pub p2p_type: i64,
#[serde(rename = "m_p2p")]
pub m_p2p: bool,
#[serde(rename = "m_servers")]
pub m_servers: Value,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoTypeListData {
pub typelist: Vec<Typelist>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Typelist {
pub id: i64,
pub parent: i64,
#[serde(rename = "parent_name")]
pub parent_name: String,
pub name: String,
pub description: String,
pub desc: String,
#[serde(rename = "intro_original")]
pub intro_original: String,
#[serde(rename = "intro_copy")]
pub intro_copy: String,
pub notice: String,
#[serde(rename = "copy_right")]
pub copy_right: i64,
pub show: bool,
pub rank: i64,
pub children: Vec<Children>,
#[serde(rename = "max_video_count")]
pub max_video_count: i64,
#[serde(rename = "request_id")]
pub request_id: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Children {
pub id: i64,
pub parent: i64,
#[serde(rename = "parent_name")]
pub parent_name: String,
pub name: String,
pub description: String,
pub desc: String,
#[serde(rename = "intro_original")]
pub intro_original: String,
#[serde(rename = "intro_copy")]
pub intro_copy: String,
pub notice: String,
#[serde(rename = "copy_right")]
pub copy_right: i64,
pub show: bool,
pub rank: i64,
#[serde(rename = "max_video_count")]
pub max_video_count: i64,
#[serde(rename = "request_id")]
pub request_id: String,
}

View File

@@ -23,6 +23,7 @@ impl DanmuStorage {
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(file_path)
.await;
if file.is_err() {
@@ -45,10 +46,10 @@ impl DanmuStorage {
.open(file_path)
.await
.expect("create danmu.txt failed");
return Some(DanmuStorage {
Some(DanmuStorage {
cache: RwLock::new(preload_cache),
file: RwLock::new(file),
});
})
}
pub async fn add_line(&self, ts: u64, content: &str) {

View File

@@ -0,0 +1,549 @@
pub mod client;
mod response;
use super::entry::{EntryStore, TsEntry};
use super::{
danmu::DanmuEntry, errors::RecorderError, PlatformType, Recorder, RecorderInfo, RoomInfo,
UserInfo,
};
use crate::database::Database;
use crate::ffmpeg::{transcode, TranscodeConfig};
use crate::{config::Config, database::account::AccountRow};
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use client::DouyinClientError;
use dashmap::DashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::RwLock;
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum LiveStatus {
Live,
Offline,
}
impl From<std::io::Error> for RecorderError {
fn from(err: std::io::Error) -> Self {
RecorderError::IoError { err }
}
}
impl From<DouyinClientError> for RecorderError {
fn from(err: DouyinClientError) -> Self {
RecorderError::DouyinClientError { err }
}
}
#[derive(Clone)]
pub struct DouyinRecorder {
client: client::DouyinClient,
db: Arc<Database>,
pub room_id: u64,
pub room_info: Arc<RwLock<Option<response::DouyinRoomInfoResponse>>>,
pub stream_url: Arc<RwLock<Option<String>>>,
pub entry_store: Arc<RwLock<Option<EntryStore>>>,
pub live_id: Arc<RwLock<String>>,
pub live_status: Arc<RwLock<LiveStatus>>,
running: Arc<RwLock<bool>>,
last_update: Arc<RwLock<i64>>,
m3u8_cache: DashMap<String, String>,
config: Arc<RwLock<Config>>,
}
impl DouyinRecorder {
pub fn new(
room_id: u64,
config: Arc<RwLock<Config>>,
douyin_account: &AccountRow,
db: &Arc<Database>,
) -> Self {
let client = client::DouyinClient::new(douyin_account);
Self {
db: db.clone(),
room_id,
live_id: Arc::new(RwLock::new(String::new())),
entry_store: Arc::new(RwLock::new(None)),
client,
room_info: Arc::new(RwLock::new(None)),
stream_url: Arc::new(RwLock::new(None)),
live_status: Arc::new(RwLock::new(LiveStatus::Offline)),
running: Arc::new(RwLock::new(false)),
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
m3u8_cache: DashMap::new(),
config,
}
}
async fn check_status(&self) -> bool {
match self.client.get_room_info(self.room_id).await {
Ok(info) => {
let live_status = info.data.room_status == 0; // room_status == 0 表示正在直播
if (*self.live_status.read().await == LiveStatus::Live) != live_status {
log::info!("[{}]Live status changed: {}", self.room_id, live_status);
if live_status {
// 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();
*self.live_status.write().await = LiveStatus::Live;
// 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,
)
.await
{
log::error!("Failed to add record: {}", e);
}
// 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);
}
} else {
*self.live_status.write().await = LiveStatus::Offline;
self.reset().await;
}
}
*self.room_info.write().await = Some(info);
live_status
}
Err(e) => {
log::error!("[{}]Update room status failed: {}", self.room_id, e);
*self.live_status.read().await == LiveStatus::Live
}
}
}
async fn reset(&self) {
*self.entry_store.write().await = None;
*self.live_id.write().await = String::new();
*self.last_update.write().await = Utc::now().timestamp();
}
async fn get_work_dir(&self, live_id: &str) -> String {
format!(
"{}/douyin/{}/{}/",
self.config.read().await.cache,
self.room_id,
live_id
)
}
async fn get_best_stream_url(
&self,
room_info: &response::DouyinRoomInfoResponse,
) -> Option<String> {
let stream_url = room_info.data.data[0]
.stream_url
.as_ref()
.unwrap()
.hls_pull_url_map
.clone();
if let Some(url) = stream_url.full_hd1 {
Some(url)
} else if let Some(url) = stream_url.hd1 {
Some(url)
} else if let Some(url) = stream_url.sd1 {
Some(url)
} else {
stream_url.sd2
}
}
async fn update_entries(&self) -> Result<u128, RecorderError> {
let task_begin_time = std::time::Instant::now();
// Get current room info and stream URL
let room_info = self.room_info.read().await;
if room_info.is_none() {
return Err(RecorderError::NoRoomInfo);
}
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);
}
*self.stream_url.write().await = Some(new_stream_url.unwrap());
}
let 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);
let mut new_segment_fetched = false;
let work_dir = self.get_work_dir(self.live_id.read().await.as_str()).await;
// Create work directory if not exists
tokio::fs::create_dir_all(&work_dir).await?;
let last_sequence = self
.entry_store
.read()
.await
.as_ref()
.unwrap()
.last_sequence();
let continue_sequence = self
.entry_store
.read()
.await
.as_ref()
.unwrap()
.continue_sequence;
let mut sequence = playlist.media_sequence + continue_sequence;
for segment in playlist.segments {
if sequence <= last_sequence {
sequence += 1;
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();
}
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)
};
let file_name = format!("{}.ts", sequence);
// 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(),
is_header: false,
};
self.entry_store
.write()
.await
.as_mut()
.unwrap()
.add_entry(ts_entry)
.await;
}
Err(e) => {
log::error!("Failed to download segment: {}", e);
}
}
sequence += 1;
}
if new_segment_fetched {
*self.last_update.write().await = Utc::now().timestamp();
self.update_record().await;
}
Ok(task_begin_time.elapsed().as_millis())
}
async fn update_record(&self) {
if let Err(e) = self
.db
.update_record(
self.live_id.read().await.as_str(),
self.entry_store
.read()
.await
.as_ref()
.unwrap()
.total_duration() as i64,
self.entry_store.read().await.as_ref().unwrap().total_size(),
)
.await
{
log::error!("Failed to update record: {}", e);
}
}
async fn generate_m3u8(&self, live_id: &str) -> String {
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:3\n";
let entries = if live_id == *self.live_id.read().await {
m3u8_content += "#EXT-X-PLAYLIST-TYPE:EVENT\n";
self.entry_store
.read()
.await
.as_ref()
.unwrap()
.get_entries()
.clone()
} else {
m3u8_content += "#EXT-X-PLAYLIST-TYPE:VOD\n";
let work_dir = self.get_work_dir(live_id).await;
let entry_store = EntryStore::new(&work_dir).await;
entry_store.get_entries().clone()
};
m3u8_content += "#EXT-X-OFFSET:0\n";
if entries.is_empty() {
return m3u8_content;
}
m3u8_content += &format!(
"#EXT-X-TARGETDURATION:{}\n",
entries.last().unwrap().length as u64
);
let mut previous_seq = entries.first().unwrap().sequence;
for entry in entries {
if entry.sequence - previous_seq > 1 {
m3u8_content += "#EXT-X-DISCONTINUITY\n";
}
previous_seq = entry.sequence;
let date_str = Utc.timestamp_opt(entry.ts, 0).unwrap().to_rfc3339();
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
m3u8_content += &format!("#EXTINF:{:.2},\n", entry.length);
m3u8_content += &format!("/douyin/{}/{}/{}\n", self.room_id, live_id, entry.url);
}
if *self.live_status.read().await != LiveStatus::Live {
m3u8_content += "#EXT-X-ENDLIST\n";
}
m3u8_content
}
}
#[async_trait]
impl Recorder for DouyinRecorder {
async fn run(&self) {
*self.running.write().await = true;
let self_clone = self.clone();
tokio::spawn(async move {
while *self_clone.running.read().await {
if self_clone.check_status().await {
// Live status is ok, start recording
while *self_clone.running.read().await {
match self_clone.update_entries().await {
Ok(ms) => {
if ms < 1000 {
tokio::time::sleep(Duration::from_millis(1000 - ms as u64))
.await;
} else {
log::warn!(
"[{}]Update entries cost too long: {}ms",
self_clone.room_id,
ms
);
}
}
Err(e) => {
log::error!("[{}]Update entries error: {}", self_clone.room_id, e);
break;
}
}
}
// Check status again after 2-5 seconds
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
// Check live status every 10s
tokio::time::sleep(Duration::from_secs(10)).await;
}
log::info!("recording thread {} quit.", self_clone.room_id);
});
}
async fn stop(&self) {
*self.running.write().await = false;
}
async fn clip_range(
&self,
live_id: &str,
x: f64,
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
let work_dir = self.get_work_dir(live_id).await;
let entries = if live_id == *self.live_id.read().await {
self.entry_store
.read()
.await
.as_ref()
.unwrap()
.get_entries()
.clone()
} else {
let entry_store = EntryStore::new(&work_dir).await;
entry_store.get_entries().clone()
};
if entries.is_empty() {
return Err(RecorderError::EmptyCache);
}
let mut file_list = Vec::new();
let mut offset = 0.0;
for entry in entries {
if offset >= x && offset <= y {
file_list.push(format!("{}/{}", work_dir, entry.url));
}
offset += entry.length;
if offset > y {
break;
}
}
let file_name = format!(
"[{}]{}_{}_{:.1}.ts",
self.room_id,
live_id,
Utc::now().format("%m%d%H%M%S"),
y - x
);
let output_file = format!("{}/{}", output_path, file_name);
tokio::fs::create_dir_all(output_path)
.await
.map_err(|e| RecorderError::IoError { err: e })?;
// Merge ts files
let mut output = tokio::fs::File::create(&output_file)
.await
.map_err(|e| RecorderError::IoError { err: e })?;
for file_path in file_list {
if let Ok(mut file) = tokio::fs::File::open(file_path).await {
let mut buffer = Vec::new();
if file.read_to_end(&mut buffer).await.is_ok() {
let _ = output.write_all(&buffer).await;
}
}
}
output
.flush()
.await
.map_err(|e| RecorderError::IoError { err: e })?;
let transcode_config = TranscodeConfig {
input_path: file_name.clone(),
input_format: "mpegts".to_string(),
// replace .ts with .mp4
output_path: file_name.replace(".ts", ".mp4"),
};
let transcode_result = transcode(output_path, transcode_config);
// delete the original ts file
tokio::fs::remove_file(output_file).await?;
Ok(transcode_result.unwrap().output_path)
}
async fn m3u8_content(&self, live_id: &str) -> String {
if let Some(cached) = self.m3u8_cache.get(live_id) {
return cached.clone();
}
self.generate_m3u8(live_id).await
}
async fn info(&self) -> RecorderInfo {
let room_info = self.room_info.read().await;
let room_cover_url = room_info
.as_ref()
.and_then(|info| info.data.data[0].cover.as_ref())
.map(|cover| cover.url_list[0].clone())
.unwrap_or_default();
RecorderInfo {
room_id: self.room_id,
room_info: RoomInfo {
room_id: self.room_id,
room_title: room_info
.as_ref()
.map(|info| info.data.data[0].title.clone())
.unwrap_or_default(),
room_cover: room_cover_url,
},
user_info: UserInfo {
user_id: room_info
.as_ref()
.map(|info| info.data.user.sec_uid.clone())
.unwrap_or_default(),
user_name: room_info
.as_ref()
.map(|info| info.data.user.nickname.clone())
.unwrap_or_default(),
user_avatar: room_info
.as_ref()
.map(|info| info.data.user.avatar_thumb.url_list[0].clone())
.unwrap_or_default(),
},
total_length: if let Some(store) = self.entry_store.read().await.as_ref() {
store.total_duration()
} else {
0.0
},
current_live_id: self.live_id.read().await.clone(),
live_status: *self.live_status.read().await == LiveStatus::Live,
platform: PlatformType::Douyin.as_str().to_string(),
}
}
async fn comments(&self, _live_id: &str) -> Result<Vec<DanmuEntry>, RecorderError> {
Ok(vec![])
}
async fn is_recording(&self, live_id: &str) -> bool {
*self.live_id.read().await == live_id && *self.live_status.read().await == LiveStatus::Live
}
}

View File

@@ -0,0 +1,123 @@
use base64::Engine;
use reqwest::{Client, Error as ReqwestError};
use m3u8_rs::{Playlist, MediaPlaylist};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use crate::database::account::AccountRow;
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),
Io(std::io::Error),
Playlist(String),
}
impl fmt::Display for DouyinClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(e) => write!(f, "Network error: {}", e),
Self::Io(e) => write!(f, "IO error: {}", e),
Self::Playlist(e) => write!(f, "Playlist error: {}", e),
}
}
}
impl From<ReqwestError> for DouyinClientError {
fn from(err: ReqwestError) -> Self {
DouyinClientError::Network(err)
}
}
impl From<std::io::Error> for DouyinClientError {
fn from(err: std::io::Error) -> Self {
DouyinClientError::Io(err)
}
}
#[derive(Clone)]
pub struct DouyinClient {
client: Client,
cookies: String,
}
impl DouyinClient {
pub fn new(account: &AccountRow) -> Self {
let client = Client::builder()
.user_agent(USER_AGENT)
.build()
.unwrap();
Self { client, cookies: account.cookies.clone() }
}
pub async fn get_room_info(&self, room_id: u64) -> Result<DouyinRoomInfoResponse, 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={}",
room_id
);
let resp = self.client.get(&url)
.header("Referer", "https://live.douyin.com/")
.header("User-Agent", USER_AGENT)
.header("Cookie", self.cookies.clone())
.send()
.await?
.json::<DouyinRoomInfoResponse>()
.await?;
Ok(resp)
}
pub async fn get_cover_base64(&self, url: &str) -> Result<String, DouyinClientError> {
log::info!("get_cover_base64: {}", url);
let response = self.client.get(url).send().await?;
let bytes = response.bytes().await?;
let base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let mime_type = mime_guess::from_path(url).first_or_octet_stream().to_string();
Ok(format!("data:{};base64,{}", mime_type, base64))
}
pub async fn get_m3u8_content(&self, url: &str) -> Result<(MediaPlaylist, String), DouyinClientError> {
let content = self.client.get(url)
.send()
.await?
.text()
.await?;
// m3u8 content: #EXTM3U
// #EXT-X-VERSION:3
// #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") {
let new_url = content.lines().last().unwrap();
return Box::pin(self.get_m3u8_content(new_url)).await;
}
match m3u8_rs::parse_playlist_res(content.as_bytes()) {
Ok(Playlist::MasterPlaylist(_)) => {
Err(DouyinClientError::Playlist("Unexpected master playlist".to_string()))
}
Ok(Playlist::MediaPlaylist(pl)) => Ok((pl, url.to_string())),
Err(e) => Err(DouyinClientError::Playlist(e.to_string())),
}
}
pub async fn download_ts(&self, url: &str, path: &str) -> Result<u64, DouyinClientError> {
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 content = response.bytes().await?;
let mut file = File::create(path).await?;
file.write_all(&content).await?;
Ok(content.len() as u64)
}
}

View File

@@ -0,0 +1,592 @@
use serde_derive::Deserialize;
use serde_derive::Serialize;
use serde_json::Value;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DouyinRoomInfoResponse {
pub data: Data,
#[serde(rename = "status_code")]
pub status_code: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Data {
pub data: Vec<Daum>,
pub user: User,
#[serde(rename = "room_status")]
pub room_status: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Daum {
#[serde(rename = "id_str")]
pub id_str: String,
pub status: i64,
#[serde(rename = "status_str")]
pub status_str: String,
pub title: String,
pub cover: Option<Cover>,
#[serde(rename = "stream_url")]
pub stream_url: Option<StreamUrl>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cover {
#[serde(rename = "url_list")]
pub url_list: Vec<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamUrl {
#[serde(rename = "flv_pull_url")]
pub flv_pull_url: FlvPullUrl,
#[serde(rename = "default_resolution")]
pub default_resolution: String,
#[serde(rename = "hls_pull_url_map")]
pub hls_pull_url_map: HlsPullUrlMap,
#[serde(rename = "hls_pull_url")]
pub hls_pull_url: String,
#[serde(rename = "stream_orientation")]
pub stream_orientation: i64,
#[serde(rename = "live_core_sdk_data")]
pub live_core_sdk_data: LiveCoreSdkData,
pub extra: Extra,
#[serde(rename = "pull_datas")]
pub pull_datas: PullDatas,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FlvPullUrl {
#[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 HlsPullUrlMap {
#[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 LiveCoreSdkData {
#[serde(rename = "pull_data")]
pub pull_data: PullData,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PullData {
pub options: Options,
#[serde(rename = "stream_data")]
pub stream_data: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Options {
#[serde(rename = "default_quality")]
pub default_quality: DefaultQuality,
pub qualities: Vec<Quality>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DefaultQuality {
pub name: String,
#[serde(rename = "sdk_key")]
pub sdk_key: String,
#[serde(rename = "v_codec")]
pub v_codec: String,
pub resolution: String,
pub level: i64,
#[serde(rename = "v_bit_rate")]
pub v_bit_rate: i64,
#[serde(rename = "additional_content")]
pub additional_content: String,
pub fps: i64,
pub disable: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Quality {
pub name: String,
#[serde(rename = "sdk_key")]
pub sdk_key: String,
#[serde(rename = "v_codec")]
pub v_codec: String,
pub resolution: String,
pub level: i64,
#[serde(rename = "v_bit_rate")]
pub v_bit_rate: i64,
#[serde(rename = "additional_content")]
pub additional_content: String,
pub fps: i64,
pub disable: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Extra {
pub height: i64,
pub width: i64,
pub fps: i64,
#[serde(rename = "max_bitrate")]
pub max_bitrate: i64,
#[serde(rename = "min_bitrate")]
pub min_bitrate: i64,
#[serde(rename = "default_bitrate")]
pub default_bitrate: i64,
#[serde(rename = "bitrate_adapt_strategy")]
pub bitrate_adapt_strategy: i64,
#[serde(rename = "anchor_interact_profile")]
pub anchor_interact_profile: i64,
#[serde(rename = "audience_interact_profile")]
pub audience_interact_profile: i64,
#[serde(rename = "hardware_encode")]
pub hardware_encode: bool,
#[serde(rename = "video_profile")]
pub video_profile: i64,
#[serde(rename = "h265_enable")]
pub h265_enable: bool,
#[serde(rename = "gop_sec")]
pub gop_sec: i64,
#[serde(rename = "bframe_enable")]
pub bframe_enable: bool,
pub roi: bool,
#[serde(rename = "sw_roi")]
pub sw_roi: bool,
#[serde(rename = "bytevc1_enable")]
pub bytevc1_enable: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PullDatas {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Owner {
#[serde(rename = "id_str")]
pub id_str: String,
#[serde(rename = "sec_uid")]
pub sec_uid: String,
pub nickname: String,
#[serde(rename = "avatar_thumb")]
pub avatar_thumb: AvatarThumb,
#[serde(rename = "follow_info")]
pub follow_info: FollowInfo,
pub subscribe: Subscribe,
#[serde(rename = "foreign_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 AvatarThumb {
#[serde(rename = "url_list")]
pub url_list: Vec<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FollowInfo {
#[serde(rename = "follow_status")]
pub follow_status: i64,
#[serde(rename = "follow_status_str")]
pub follow_status_str: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Subscribe {
#[serde(rename = "is_member")]
pub is_member: bool,
pub level: i64,
#[serde(rename = "identity_type")]
pub identity_type: i64,
#[serde(rename = "buy_type")]
pub buy_type: i64,
pub open: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoomAuth {
#[serde(rename = "Chat")]
pub chat: bool,
#[serde(rename = "Danmaku")]
pub danmaku: bool,
#[serde(rename = "Gift")]
pub gift: bool,
#[serde(rename = "LuckMoney")]
pub luck_money: bool,
#[serde(rename = "Digg")]
pub digg: bool,
#[serde(rename = "RoomContributor")]
pub room_contributor: bool,
#[serde(rename = "Props")]
pub props: bool,
#[serde(rename = "UserCard")]
pub user_card: bool,
#[serde(rename = "POI")]
pub poi: bool,
#[serde(rename = "MoreAnchor")]
pub more_anchor: i64,
#[serde(rename = "Banner")]
pub banner: i64,
#[serde(rename = "Share")]
pub share: i64,
#[serde(rename = "UserCorner")]
pub user_corner: i64,
#[serde(rename = "Landscape")]
pub landscape: i64,
#[serde(rename = "LandscapeChat")]
pub landscape_chat: i64,
#[serde(rename = "PublicScreen")]
pub public_screen: i64,
#[serde(rename = "GiftAnchorMt")]
pub gift_anchor_mt: i64,
#[serde(rename = "RecordScreen")]
pub record_screen: i64,
#[serde(rename = "DonationSticker")]
pub donation_sticker: i64,
#[serde(rename = "HourRank")]
pub hour_rank: i64,
#[serde(rename = "CommerceCard")]
pub commerce_card: i64,
#[serde(rename = "AudioChat")]
pub audio_chat: i64,
#[serde(rename = "DanmakuDefault")]
pub danmaku_default: i64,
#[serde(rename = "KtvOrderSong")]
pub ktv_order_song: i64,
#[serde(rename = "SelectionAlbum")]
pub selection_album: i64,
#[serde(rename = "Like")]
pub like: i64,
#[serde(rename = "MultiplierPlayback")]
pub multiplier_playback: i64,
#[serde(rename = "DownloadVideo")]
pub download_video: i64,
#[serde(rename = "Collect")]
pub collect: i64,
#[serde(rename = "TimedShutdown")]
pub timed_shutdown: i64,
#[serde(rename = "Seek")]
pub seek: i64,
#[serde(rename = "Denounce")]
pub denounce: i64,
#[serde(rename = "Dislike")]
pub dislike: i64,
#[serde(rename = "OnlyTa")]
pub only_ta: i64,
#[serde(rename = "CastScreen")]
pub cast_screen: i64,
#[serde(rename = "CommentWall")]
pub comment_wall: i64,
#[serde(rename = "BulletStyle")]
pub bullet_style: i64,
#[serde(rename = "ShowGamePlugin")]
pub show_game_plugin: i64,
#[serde(rename = "VSGift")]
pub vsgift: i64,
#[serde(rename = "VSTopic")]
pub vstopic: i64,
#[serde(rename = "VSRank")]
pub vsrank: i64,
#[serde(rename = "AdminCommentWall")]
pub admin_comment_wall: i64,
#[serde(rename = "CommerceComponent")]
pub commerce_component: i64,
#[serde(rename = "DouPlus")]
pub dou_plus: i64,
#[serde(rename = "GamePointsPlaying")]
pub game_points_playing: i64,
#[serde(rename = "Poster")]
pub poster: i64,
#[serde(rename = "Highlights")]
pub highlights: i64,
#[serde(rename = "TypingCommentState")]
pub typing_comment_state: i64,
#[serde(rename = "StrokeUpDownGuide")]
pub stroke_up_down_guide: i64,
#[serde(rename = "UpRightStatsFloatingLayer")]
pub up_right_stats_floating_layer: i64,
#[serde(rename = "CastScreenExplicit")]
pub cast_screen_explicit: i64,
#[serde(rename = "Selection")]
pub selection: i64,
#[serde(rename = "IndustryService")]
pub industry_service: i64,
#[serde(rename = "VerticalRank")]
pub vertical_rank: i64,
#[serde(rename = "EnterEffects")]
pub enter_effects: i64,
#[serde(rename = "FansClub")]
pub fans_club: i64,
#[serde(rename = "EmojiOutside")]
pub emoji_outside: i64,
#[serde(rename = "CanSellTicket")]
pub can_sell_ticket: i64,
#[serde(rename = "DouPlusPopularityGem")]
pub dou_plus_popularity_gem: i64,
#[serde(rename = "MissionCenter")]
pub mission_center: i64,
#[serde(rename = "ExpandScreen")]
pub expand_screen: i64,
#[serde(rename = "FansGroup")]
pub fans_group: i64,
#[serde(rename = "Topic")]
pub topic: i64,
#[serde(rename = "AnchorMission")]
pub anchor_mission: i64,
#[serde(rename = "Teleprompter")]
pub teleprompter: i64,
#[serde(rename = "LongTouch")]
pub long_touch: i64,
#[serde(rename = "FirstFeedHistChat")]
pub first_feed_hist_chat: i64,
#[serde(rename = "MoreHistChat")]
pub more_hist_chat: i64,
#[serde(rename = "TaskBanner")]
pub task_banner: i64,
#[serde(rename = "SpecialStyle")]
pub special_style: SpecialStyle,
#[serde(rename = "FixedChat")]
pub fixed_chat: i64,
#[serde(rename = "QuizGamePointsPlaying")]
pub quiz_game_points_playing: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpecialStyle {
#[serde(rename = "Chat")]
pub chat: Chat,
#[serde(rename = "Like")]
pub like: Like,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Chat {
#[serde(rename = "UnableStyle")]
pub unable_style: i64,
#[serde(rename = "Content")]
pub content: String,
#[serde(rename = "OffType")]
pub off_type: i64,
#[serde(rename = "AnchorSwitchForPaidLive")]
pub anchor_switch_for_paid_live: i64,
#[serde(rename = "ContentForPaidLive")]
pub content_for_paid_live: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Like {
#[serde(rename = "UnableStyle")]
pub unable_style: i64,
#[serde(rename = "Content")]
pub content: String,
#[serde(rename = "OffType")]
pub off_type: i64,
#[serde(rename = "AnchorSwitchForPaidLive")]
pub anchor_switch_for_paid_live: i64,
#[serde(rename = "ContentForPaidLive")]
pub content_for_paid_live: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Stats {
#[serde(rename = "total_user_desp")]
pub total_user_desp: String,
#[serde(rename = "like_count")]
pub like_count: i64,
#[serde(rename = "total_user_str")]
pub total_user_str: String,
#[serde(rename = "user_count_str")]
pub user_count_str: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkerMap {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkerDetail {
#[serde(rename = "linker_play_modes")]
pub linker_play_modes: Vec<Value>,
#[serde(rename = "big_party_layout_config_version")]
pub big_party_layout_config_version: i64,
#[serde(rename = "accept_audience_pre_apply")]
pub accept_audience_pre_apply: bool,
#[serde(rename = "linker_ui_layout")]
pub linker_ui_layout: i64,
#[serde(rename = "enable_audience_linkmic")]
pub enable_audience_linkmic: i64,
#[serde(rename = "function_type")]
pub function_type: String,
#[serde(rename = "linker_map_str")]
pub linker_map_str: LinkerMapStr,
#[serde(rename = "ktv_lyric_mode")]
pub ktv_lyric_mode: String,
#[serde(rename = "init_source")]
pub init_source: String,
#[serde(rename = "forbid_apply_from_other")]
pub forbid_apply_from_other: bool,
#[serde(rename = "ktv_exhibit_mode")]
pub ktv_exhibit_mode: i64,
#[serde(rename = "enlarge_guest_turn_on_source")]
pub enlarge_guest_turn_on_source: i64,
#[serde(rename = "playmode_detail")]
pub playmode_detail: PlaymodeDetail,
#[serde(rename = "client_ui_info")]
pub client_ui_info: String,
#[serde(rename = "manual_open_ui")]
pub manual_open_ui: i64,
#[serde(rename = "feature_list")]
pub feature_list: Vec<Value>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkerMapStr {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaymodeDetail {
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoomViewStats {
#[serde(rename = "is_hidden")]
pub is_hidden: bool,
#[serde(rename = "display_short")]
pub display_short: String,
#[serde(rename = "display_middle")]
pub display_middle: String,
#[serde(rename = "display_long")]
pub display_long: String,
#[serde(rename = "display_value")]
pub display_value: i64,
#[serde(rename = "display_version")]
pub display_version: i64,
pub incremental: bool,
#[serde(rename = "display_type")]
pub display_type: i64,
#[serde(rename = "display_short_anchor")]
pub display_short_anchor: String,
#[serde(rename = "display_middle_anchor")]
pub display_middle_anchor: String,
#[serde(rename = "display_long_anchor")]
pub display_long_anchor: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneTypeInfo {
#[serde(rename = "is_union_live_room")]
pub is_union_live_room: bool,
#[serde(rename = "is_life")]
pub is_life: bool,
#[serde(rename = "is_protected_room")]
pub is_protected_room: i64,
#[serde(rename = "is_lasted_goods_room")]
pub is_lasted_goods_room: i64,
#[serde(rename = "is_desire_room")]
pub is_desire_room: i64,
#[serde(rename = "commentary_type")]
pub commentary_type: bool,
#[serde(rename = "is_sub_orientation_vertical_room")]
pub is_sub_orientation_vertical_room: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EntranceList {
#[serde(rename = "group_id")]
pub group_id: i64,
#[serde(rename = "component_type")]
pub component_type: i64,
#[serde(rename = "op_type")]
pub op_type: i64,
pub text: String,
#[serde(rename = "schema_url")]
pub schema_url: String,
#[serde(rename = "show_type")]
pub show_type: i64,
#[serde(rename = "data_status")]
pub data_status: i64,
pub extra: String,
pub icon: Option<Icon>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Icon {
#[serde(rename = "url_list")]
pub url_list: Vec<String>,
pub uri: String,
pub height: i64,
pub width: i64,
#[serde(rename = "avg_color")]
pub avg_color: String,
#[serde(rename = "image_type")]
pub image_type: i64,
#[serde(rename = "open_web_url")]
pub open_web_url: String,
#[serde(rename = "is_animated")]
pub is_animated: bool,
#[serde(rename = "flex_setting_list")]
pub flex_setting_list: Vec<Value>,
#[serde(rename = "text_setting_list")]
pub text_setting_list: Vec<Value>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
#[serde(rename = "id_str")]
pub id_str: String,
#[serde(rename = "sec_uid")]
pub sec_uid: String,
pub nickname: String,
#[serde(rename = "avatar_thumb")]
pub avatar_thumb: AvatarThumb,
#[serde(rename = "follow_info")]
pub follow_info: FollowInfo,
#[serde(rename = "foreign_user")]
pub foreign_user: i64,
#[serde(rename = "open_id_str")]
pub open_id_str: String,
}

View File

@@ -0,0 +1,149 @@
use async_std::{
fs::{File, OpenOptions},
io::{prelude::BufReadExt, BufReader, WriteExt},
path::Path,
stream::StreamExt,
};
const ENTRY_FILE_NAME: &str = "entries.log";
#[derive(Clone)]
pub struct TsEntry {
pub url: String,
pub sequence: u64,
pub length: f64,
pub size: u64,
pub ts: i64,
pub is_header: bool,
}
pub struct EntryStore {
// append only log file
log_file: File,
header: Option<TsEntry>,
entries: Vec<TsEntry>,
total_duration: f64,
total_size: u64,
last_sequence: u64,
pub continue_sequence: u64,
}
impl EntryStore {
pub async fn new(work_dir: &str) -> Self {
// if work_dir is not exists, create it
if !Path::new(work_dir).exists().await {
std::fs::create_dir_all(work_dir).unwrap();
}
// open append only log file
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(format!("{}/{}", work_dir, ENTRY_FILE_NAME))
.await
.unwrap();
let mut entry_store = Self {
log_file,
header: None,
entries: vec![],
total_duration: 0.0,
total_size: 0,
last_sequence: 0,
continue_sequence: 0,
};
entry_store.load(work_dir).await;
entry_store
}
async fn load(&mut self, work_dir: &str) {
let file = OpenOptions::new()
.create(false)
.read(true)
.open(format!("{}/{}", work_dir, ENTRY_FILE_NAME))
.await
.unwrap();
let mut lines = BufReader::new(file).lines();
while let Some(Ok(line)) = lines.next().await {
let parts: Vec<&str> = line.split('|').collect();
let entry = TsEntry {
url: parts[0].to_string(),
sequence: parts[1].parse().unwrap(),
length: parts[2].parse().unwrap(),
size: parts[3].parse().unwrap(),
ts: parts[4].parse().unwrap(),
is_header: parts[5].parse().unwrap(),
};
if entry.sequence > self.last_sequence {
self.last_sequence = entry.sequence;
}
if entry.is_header {
self.header = Some(entry.clone());
} else {
self.entries.push(entry.clone());
}
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) {
if entry.is_header {
self.header = Some(entry.clone());
} else {
self.entries.push(entry.clone());
}
if let Err(e) = self
.log_file
.write_all(
format!(
"{}|{}|{}|{}|{}|{}\n",
entry.url, entry.sequence, entry.length, entry.size, entry.ts, entry.is_header
)
.as_bytes(),
)
.await
{
log::error!("Failed to write entry to log file: {}", e);
}
self.log_file.flush().await.unwrap();
if self.last_sequence < entry.sequence {
self.last_sequence = entry.sequence;
}
self.total_duration += entry.length;
self.total_size += entry.size;
}
pub fn get_header(&self) -> Option<&TsEntry> {
self.header.as_ref()
}
pub fn get_entries(&self) -> &Vec<TsEntry> {
&self.entries
}
pub fn total_duration(&self) -> f64 {
self.total_duration
}
pub fn total_size(&self) -> u64 {
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)
}
}

View File

@@ -0,0 +1,23 @@
use super::bilibili::client::BiliStream;
use super::douyin::client::DouyinClientError;
use custom_error::custom_error;
custom_error! {pub RecorderError
IndexNotFound {url: String} = "Index not found: {url}",
ArchiveInUse {live_id: String} = "Can not delete current stream: {live_id}",
EmptyCache = "Cache is empty",
M3u8ParseFailed {content: String } = "Parse m3u8 content failed: {content}",
NoStreamAvailable = "No available stream provided",
FreezedStream {stream: BiliStream} = "Stream is freezed: {stream}",
StreamExpired {stream: BiliStream} = "Stream is nearly expired: {stream}",
NoRoomInfo = "No room info provided",
InvalidStream {stream: BiliStream} = "Invalid stream: {stream}",
SlowStream {stream: BiliStream} = "Stream is too slow: {stream}",
EmptyHeader = "Header url is empty",
InvalidTimestamp = "Header timestamp is invalid",
InvalidDBOP {err: crate::database::DatabaseError } = "Database error: {err}",
BiliClientError {err: super::bilibili::errors::BiliClientError} = "BiliClient error: {err}",
DouyinClientError {err: DouyinClientError} = "DouyinClient error: {err}",
ClipError {err: String} = "FFMPEG error: {err}",
IoError {err: std::io::Error} = "IO error: {err}",
}

View File

View File

@@ -1,16 +1,20 @@
use crate::db::{AccountRow, Database, RecordRow};
use crate::recorder::bilibili::UserInfo;
use crate::config::Config;
use crate::database::DatabaseError;
use crate::database::{account::AccountRow, record::RecordRow, Database};
use crate::recorder::bilibili::BiliRecorder;
use crate::recorder::danmu::DanmuEntry;
use crate::recorder::RecorderError;
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
use crate::Config;
use crate::recorder::douyin::DouyinRecorder;
use crate::recorder::errors::RecorderError;
use crate::recorder::PlatformType;
use crate::recorder::Recorder;
use crate::recorder::RecorderInfo;
use custom_error::custom_error;
use dashmap::DashMap;
use hyper::Method;
use hyper::{
service::{make_service_fn, service_fn},
Body, Request, Response, Server,
};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::{convert::Infallible, sync::Arc};
use tauri::AppHandle;
@@ -22,29 +26,23 @@ pub struct RecorderList {
pub recorders: Vec<RecorderInfo>,
}
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct RecorderInfo {
pub room_id: u64,
pub room_info: RoomInfo,
pub user_info: UserInfo,
pub total_length: f64,
pub current_ts: u64,
pub live_status: bool,
}
pub struct RecorderManager {
app_handle: AppHandle,
db: Arc<Database>,
config: Arc<RwLock<Config>>,
recorders: Arc<DashMap<u64, BiliRecorder>>,
recorders: Arc<RwLock<HashMap<String, Box<dyn Recorder>>>>,
hls_server_addr: Arc<RwLock<Option<SocketAddr>>>,
}
custom_error! {pub RecorderManagerError
AlreadyExisted { room_id: u64 } = "Recorder {room_id} already existed",
NotFound {room_id: u64 } = "Recorder {room_id} not found",
RecorderError { err: RecorderError } = "Recorder error",
IOError {err: std::io::Error } = "IO error",
HLSError { err: hyper::Error } = "HLS server error",
AlreadyExisted { room_id: u64 } = "房间 {room_id} 已存在",
NotFound {room_id: u64 } = "房间 {room_id} 不存在",
InvalidPlatformType { platform: String } = "不支持的平台: {platform}",
RecorderError { err: RecorderError } = "录播器错误: {err}",
IOError {err: std::io::Error } = "IO 错误: {err}",
HLSError { err: hyper::Error } = "HLS 服务器错误: {err}",
DatabaseError { err: DatabaseError } = "数据库错误: {err}",
Recording { live_id: String } = "无法删除正在录制的直播 {live_id}",
}
impl From<hyper::Error> for RecorderManagerError {
@@ -65,6 +63,12 @@ impl From<RecorderError> for RecorderManagerError {
}
}
impl From<DatabaseError> for RecorderManagerError {
fn from(value: DatabaseError) -> Self {
RecorderManagerError::DatabaseError { err: value }
}
}
impl From<RecorderManagerError> for String {
fn from(value: RecorderManagerError) -> Self {
value.to_string()
@@ -72,11 +76,16 @@ impl From<RecorderManagerError> for String {
}
impl RecorderManager {
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
pub fn new(
app_handle: AppHandle,
db: Arc<Database>,
config: Arc<RwLock<Config>>,
) -> RecorderManager {
RecorderManager {
app_handle,
db,
config,
recorders: Arc::new(DashMap::new()),
recorders: Arc::new(RwLock::new(HashMap::new())),
hls_server_addr: Arc::new(RwLock::new(None)),
}
}
@@ -84,118 +93,143 @@ impl RecorderManager {
/// starting HLS server
pub async fn run_hls(&self) -> Result<(), RecorderManagerError> {
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
let listener = TcpListener::bind(&addr).await?;
let server_addr = self.start_hls_server(listener).await?;
log::info!("HLS server started on {}", server_addr);
self.hls_server_addr.write().await.replace(server_addr);
let listener = TcpListener::bind(addr).await?;
let addr = self.start_hls_server(listener).await?;
*self.hls_server_addr.write().await = Some(addr);
Ok(())
}
pub async fn add_recorder(
&self,
webid: &str,
db: &Arc<Database>,
account: &AccountRow,
platform: PlatformType,
room_id: u64,
) -> Result<(), RecorderManagerError> {
// check existing recorder
if self.recorders.contains_key(&room_id) {
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if self.recorders.read().await.contains_key(&recorder_id) {
return Err(RecorderManagerError::AlreadyExisted { room_id });
}
let recorder = BiliRecorder::new(
self.app_handle.clone(),
webid,
db,
room_id,
account,
self.config.clone(),
)
.await?;
self.recorders.insert(room_id, recorder);
// run recorder
let recorder = self.recorders.get(&room_id).unwrap();
recorder.value().run().await;
Ok(())
}
pub async fn remove_recorder(&self, room_id: u64) -> Result<(), RecorderManagerError> {
let recorder = self.recorders.remove(&room_id);
if recorder.is_none() {
return Err(RecorderManagerError::NotFound { room_id });
let recorder: Box<dyn Recorder + 'static> = match platform {
PlatformType::BiliBili => Box::new(
BiliRecorder::new(
self.app_handle.clone(),
webid,
&self.db,
room_id,
account,
self.config.clone(),
)
.await?,
),
PlatformType::Douyin => Box::new(DouyinRecorder::new(
room_id,
self.config.clone(),
account,
&self.db,
)),
_ => {
return Err(RecorderManagerError::InvalidPlatformType {
platform: platform.as_str().to_string(),
})
}
};
self.recorders
.write()
.await
.insert(recorder_id.clone(), recorder);
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
recorder_ref.run().await;
}
// remove related cache folder
let cache_folder = format!("{}/{}", self.config.read().await.cache, room_id);
tokio::fs::remove_dir_all(cache_folder).await?;
Ok(())
}
pub async fn clip(
pub async fn stop_all(&self) {
for recorder_ref in self.recorders.read().await.values() {
recorder_ref.stop().await;
}
// remove all recorders
self.recorders.write().await.clear();
}
pub async fn remove_recorder(
&self,
output_path: &str,
platform: PlatformType,
room_id: u64,
d: f64,
) -> Result<String, RecorderManagerError> {
let recorder = self.recorders.get(&room_id);
if recorder.is_none() {
) -> Result<(), RecorderManagerError> {
// check recorder exists
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if !self.recorders.read().await.contains_key(&recorder_id) {
return Err(RecorderManagerError::NotFound { room_id });
}
let recorder = recorder.unwrap();
Ok(recorder.value().clip(room_id, d, output_path).await?)
// stop recorder
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
recorder_ref.stop().await;
}
// remove recorder
self.recorders.write().await.remove(&recorder_id);
// remove related cache folder
let cache_folder = format!(
"{}/{}/{}",
self.config.read().await.cache,
platform.as_str(),
room_id
);
let _ = tokio::fs::remove_dir_all(cache_folder).await;
log::info!("Recorder {} cache folder removed", room_id);
Ok(())
}
pub async fn clip_range(
&self,
output_path: &str,
platform: PlatformType,
room_id: u64,
ts: u64,
live_id: &str,
start: f64,
end: f64,
) -> Result<String, RecorderManagerError> {
let recorder = self.recorders.get(&room_id);
if recorder.is_none() {
let recorders = self.recorders.read().await;
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if !recorders.contains_key(&recorder_id) {
log::error!("Recorder {} not found", recorder_id);
return Err(RecorderManagerError::NotFound { room_id });
}
let recorder = recorder.unwrap();
let recorder = recorders.get(&recorder_id).unwrap();
Ok(recorder
.value()
.clip_range(ts, start, end, output_path)
.clip_range(live_id, start, end, output_path)
.await?)
}
pub async fn get_recorder_list(&self) -> RecorderList {
let mut summary = RecorderList {
count: self.recorders.len(),
count: self.recorders.read().await.len(),
recorders: Vec::new(),
};
for recorder in self.recorders.iter() {
let recorder = recorder.value();
let room_info = RecorderInfo {
room_id: recorder.room_id,
room_info: recorder.room_info.read().await.clone(),
user_info: recorder.user_info.read().await.clone(),
total_length: *recorder.ts_length.read().await,
current_ts: *recorder.timestamp.read().await,
live_status: *recorder.live_status.read().await,
};
for recorder_ref in self.recorders.read().await.iter() {
let room_info = recorder_ref.1.info().await;
summary.recorders.push(room_info);
}
summary.recorders.sort_by(|a, b| a.room_id.cmp(&b.room_id));
summary
}
pub async fn get_recorder_info(&self, room_id: u64) -> Option<RecorderInfo> {
if let Some(recorder) = self.recorders.get(&room_id) {
let room_info = RecorderInfo {
room_id: recorder.room_id,
room_info: recorder.room_info.read().await.clone(),
user_info: recorder.user_info.read().await.clone(),
total_length: *recorder.ts_length.read().await,
current_ts: *recorder.timestamp.read().await,
live_status: *recorder.live_status.read().await,
};
pub async fn get_recorder_info(
&self,
platform: PlatformType,
room_id: u64,
) -> Option<RecorderInfo> {
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
let room_info = recorder_ref.info().await;
Some(room_info)
} else {
None
@@ -203,38 +237,46 @@ impl RecorderManager {
}
pub async fn get_archives(&self, room_id: u64) -> Result<Vec<RecordRow>, RecorderManagerError> {
if let Some(recorder) = self.recorders.get(&room_id) {
Ok(recorder.get_archives().await?)
} else {
Err(RecorderManagerError::NotFound { room_id })
}
Ok(self.db.get_records(room_id).await?)
}
pub async fn get_archive(
&self,
room_id: u64,
live_id: u64,
live_id: &str,
) -> Result<RecordRow, RecorderManagerError> {
if let Some(recorder) = self.recorders.get(&room_id) {
Ok(recorder.get_archive(live_id).await?)
} else {
Err(RecorderManagerError::NotFound { room_id })
}
Ok(self.db.get_record(room_id, live_id).await?)
}
pub async fn delete_archive(&self, room_id: u64, ts: u64) {
if let Some(recorder) = self.recorders.get(&room_id) {
recorder.delete_archive(ts).await;
pub async fn delete_archive(
&self,
platform: PlatformType,
room_id: u64,
live_id: &str,
) -> Result<(), RecorderManagerError> {
log::info!("Deleting {}:{}", room_id, live_id);
// check if this is still recording
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();
if recorder.is_recording(live_id).await {
return Err(RecorderManagerError::Recording {
live_id: live_id.to_string(),
});
}
}
Ok(self.db.remove_record(live_id).await?)
}
pub async fn get_danmu(
&self,
platform: PlatformType,
room_id: u64,
live_id: u64,
live_id: &str,
) -> Result<Vec<DanmuEntry>, RecorderManagerError> {
if let Some(recorder) = self.recorders.get(&room_id) {
Ok(recorder.get_danmu_record(live_id).await)
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
Ok(recorder_ref.comments(live_id).await?)
} else {
Err(RecorderManagerError::NotFound { room_id })
}
@@ -266,11 +308,14 @@ impl RecorderManager {
.unwrap(),
);
}
let cache_path = config.read().await.cache.clone();
let path = req.uri().path();
let path_segs: Vec<&str> = path.split('/').collect();
// path_segs should be size 4: /21484828/{timestamp}/playlist.m3u8
if path_segs.len() != 4 {
// path_segs should be size 5: /{platform}/{room_id}/{live_id}/playlist.m3u8
if path_segs.len() != 5 {
log::warn!("Invalid request path: {}", path);
return Ok::<_, Infallible>(
Response::builder()
.status(400)
@@ -278,13 +323,18 @@ impl RecorderManager {
.unwrap(),
);
}
// parse recorder type
let platform = path_segs[1];
// parse room id
let room_id = path_segs[1].parse::<u64>().unwrap();
let timestamp = path_segs[2].parse::<u64>().unwrap();
// if path is /room_id/{timestamp}/playlist.m3u8
if path_segs[3] == "playlist.m3u8" {
let room_id = path_segs[2].parse::<u64>().unwrap();
// parse live id
let live_id = path_segs[3];
if path_segs[4] == "playlist.m3u8" {
// get recorder
let recorder = recorders.get(&room_id);
let recorder_key = format!("{}:{}", platform, room_id);
let recorders = recorders.read().await;
let recorder = recorders.get(&recorder_key);
if recorder.is_none() {
return Ok::<_, Infallible>(
Response::builder()
@@ -295,7 +345,7 @@ impl RecorderManager {
}
let recorder = recorder.unwrap();
// response with recorder generated m3u8, which contains ts entries that cached in local
let m3u8_content = recorder.value().generate_m3u8(timestamp).await;
let m3u8_content = recorder.m3u8_content(live_id).await;
Ok::<_, Infallible>(
Response::builder()
.status(200)
@@ -309,7 +359,9 @@ impl RecorderManager {
// try to find requested ts file in recorder's cache
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
let ts_file = format!("{}/{}", cache_path, path.replace("%7C", "|"));
let recorder = recorders.get(&room_id);
let recorders = recorders.read().await;
let recorder_id = format!("{}:{}", platform, room_id);
let recorder = recorders.get(&recorder_id);
if recorder.is_none() {
return Ok::<_, Infallible>(
Response::builder()

23
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,23 @@
use std::sync::Arc;
use tokio::sync::RwLock;
use custom_error::custom_error;
use crate::config::Config;
use crate::database::Database;
use crate::recorder::bilibili::client::BiliClient;
use crate::recorder_manager::RecorderManager;
custom_error! {
StateError
RecorderAlreadyExists = "Recorder already exists",
RecorderCreateError = "Recorder create error",
}
#[derive(Clone)]
pub struct State {
pub db: Arc<Database>,
pub client: Arc<BiliClient>,
pub config: Arc<RwLock<Config>>,
pub recorder_manager: Arc<RecorderManager>,
pub app_handle: tauri::AppHandle,
}

View File

@@ -21,7 +21,7 @@
"identifier": "cn.vjoi.bilishadowreplay",
"plugins": {
"sql": {
"preload": ["sqlite:data.db"]
"preload": ["sqlite:data_v2.db"]
}
},
"app": {

View File

@@ -9,7 +9,7 @@
"width": 1300,
"height": 600,
"transparent": false,
"decorations": false,
"decorations": true,
"theme": "Light"
}
]

View File

@@ -1,44 +1,29 @@
<script lang="ts">
import Room from "./lib/Room.svelte";
import Room from "./page/Room.svelte";
import BSidebar from "./lib/BSidebar.svelte";
import Summary from "./lib/Summary.svelte";
import Setting from "./lib/Setting.svelte";
import Account from "./lib/Account.svelte";
import TitleBar from "./lib/TitleBar.svelte";
import Messages from "./lib/Messages.svelte";
import About from "./lib/About.svelte";
import { platform } from "@tauri-apps/plugin-os";
import Summary from "./page/Summary.svelte";
import Setting from "./page/Setting.svelte";
import Account from "./page/Account.svelte";
import About from "./page/About.svelte";
let active = "#总览";
let room_count = 0;
let message_cnt = 0;
let use_titlebar = platform() == "windows";
</script>
<main>
{#if use_titlebar}
<TitleBar />
{/if}
<div class="wrap">
<div class="sidebar">
<BSidebar bind:activeUrl={active} {room_count} {message_cnt} />
<BSidebar bind:activeUrl={active} />
</div>
<div class="content">
<div class="content bg-white dark:bg-[#2c2c2e]">
<!-- switch component by active -->
<div class="page" class:visible={active == "#总览"}>
<Summary />
</div>
<div class="h-full page" class:visible={active == "#直播间"}>
<Room bind:room_count />
<div class="page" class:visible={active == "#直播间"}>
<Room />
</div>
<div class="h-full page" class:visible={active == "#消息"}>
<Messages bind:message_cnt />
</div>
<div class="h-full page" class:visible={active == "#账号"}>
<div class="page" class:visible={active == "#账号"}>
<Account />
</div>
<!-- <div class="page" class:visible={active == "#自动化"}>
<div>自动化[开发中]</div>
</div> -->
<div class="page" class:visible={active == "#设置"}>
<Setting />
</div>
@@ -64,22 +49,25 @@
.visible {
opacity: 1 !important;
max-height: fit-content !important;
height: 100% !important;
transform: translateX(0) !important;
}
.page {
opacity: 0;
max-height: 0;
height: 0;
transform: translateX(100%);
overflow: hidden;
transition:
opacity 0.5s ease-in-out,
transform 0.3s ease-in-out;
display: flex;
flex-direction: column;
}
.content {
width: 100%;
height: 100vh;
background-color: #e5e7eb;
overflow: hidden;
}
</style>

View File

@@ -1,33 +1,25 @@
<script lang="ts">
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import {
Button,
ButtonGroup,
Input,
Label,
Spinner,
Textarea,
Modal,
Select,
Hr,
} from "flowbite-svelte";
import Player from "./lib/Player.svelte";
import TitleBar from "./lib/TitleBar.svelte";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import html2canvas from "html2canvas";
import type { AccountInfo, RecordItem } from "./lib/db";
import { platform } from "@tauri-apps/plugin-os";
import { ClapperboardPlaySolid, PlayOutline } from "flowbite-svelte-icons";
import type { Profile, VideoItem, Config } from "./lib/interface";
import { onMount } from "svelte";
let use_titlebar = platform() == "windows";
import {
ChevronRight,
ChevronLeft,
Play,
Pen,
} from "lucide-svelte";
import type { Profile, VideoItem, Config, Marker } from "./lib/interface";
import TypeSelect from "./lib/TypeSelect.svelte";
import MarkerPanel from "./lib/MarkerPanel.svelte";
import CoverEditor from "./lib/CoverEditor.svelte";
const appWindow = getCurrentWebviewWindow();
const urlParams = new URLSearchParams(window.location.search);
const port = urlParams.get("port");
const port = parseInt(urlParams.get("port"));
const room_id = parseInt(urlParams.get("room_id"));
const ts = parseInt(urlParams.get("ts"));
const platform = urlParams.get("platform");
const live_id = urlParams.get("live_id");
// get profile in local storage with a default value
let profile: Profile = get_profile();
@@ -46,6 +38,10 @@
return default_profile();
}
$: {
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
}
function default_profile(): Profile {
return {
videos: [],
@@ -94,49 +90,47 @@
return canvas.toDataURL();
}
let cover_text = "";
let preview = false;
let show_cover_editor = false;
let text_style = {
position: { x: 8, y: 8 },
fontSize: 24,
color: "#FF7F00"
};
let uid_selected = 0;
let video_selected = 0;
$: video_src = video ? convertFileSrc(config.output + "/" + video.name) : "";
let accounts = [];
let videos = [];
let video = null;
let cover = "";
let selected_video = null;
invoke("get_accounts").then((account_info: AccountInfo) => {
accounts = account_info.accounts.map((a) => {
return {
value: a.uid,
name: a.name,
platform: a.platform,
};
});
console.log(accounts);
accounts = accounts.filter((a) => a.platform === 'bilibili');
});
get_video_list();
invoke("get_archive", { roomId: room_id, liveId: ts }).then(
invoke("get_archive", { roomId: room_id, liveId: live_id }).then(
(a: RecordItem) => {
console.log(a);
archive = a;
appWindow.setTitle(`[${room_id}][${format_ts(ts)}]${archive.title}`);
},
appWindow.setTitle(`[${room_id}]${archive.title}`);
}
);
function update_title(str: string) {
appWindow.setTitle(
`[${room_id}][${format_ts(ts)}]${archive.title} - ${str}`,
`[${room_id}]${archive.title} - ${str}`
);
}
function format_ts(ts: number) {
const date = new Date(ts * 1000);
return date.toLocaleString();
}
async function get_video_list() {
videos = (
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
@@ -144,19 +138,22 @@
return {
value: v.id,
name: v.file,
file: convertFileSrc(config.output + "/" + v.file),
cover: v.cover,
};
});
console.log(videos, video_selected);
}
function find_video(e) {
if (!e.target) {
selected_video = null;
return;
}
const id = parseInt(e.target.value);
video = videos.find((v) => {
selected_video = videos.find((v) => {
return v.value == id;
});
cover = video.cover;
console.log("video selected", videos, video, e, id);
console.log("video selected", videos, selected_video, e, id);
}
async function generate_clip() {
@@ -174,19 +171,20 @@
try {
let new_video = (await invoke("clip_range", {
roomId: room_id,
platform: platform,
cover: new_cover,
ts: ts,
liveId: live_id,
x: start,
y: end,
})) as VideoItem;
update_title(`切片生成成功`);
console.log("video file generatd:", video);
console.log("video file generatd:", selected_video);
await get_video_list();
video_selected = new_video.id;
video = videos.find((v) => {
selected_video = videos.find((v) => {
return v.value == new_video.id;
});
cover = new_video.cover;
selected_video.cover = new_video.cover;
loading = false;
} catch (e) {
alert("Err generating clip: " + e);
@@ -194,24 +192,19 @@
}
async function do_post() {
if (!video) {
if (!selected_video) {
return;
}
update_title(`投稿上传中`);
loading = true;
// render cover with text
const ecapture = document.getElementById("capture");
const render_canvas = await html2canvas(ecapture, {
scale: 720 / ecapture.clientHeight,
});
const rendered_cover = render_canvas.toDataURL();
// update profile in local storage
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
invoke("upload_procedure", {
uid: uid_selected,
roomId: room_id,
videoId: video_selected,
cover: rendered_cover,
cover: selected_video.cover,
profile: profile,
})
.then(async () => {
@@ -228,7 +221,7 @@
}
async function delete_video() {
if (!video) {
if (!selected_video) {
return;
}
loading = true;
@@ -237,148 +230,375 @@
update_title(`删除成功`);
loading = false;
video_selected = 0;
video = null;
cover = "";
selected_video = null;
await get_video_list();
}
// when window resize, update post panel height
onMount(() => {
let post_panel = document.getElementById("post-panel");
if (post_panel) {
post_panel.style.height = `calc(100vh - 35px)`;
}
window.addEventListener("resize", () => {
if (post_panel) {
post_panel.style.height = `calc(100vh - 35px)`;
}
});
});
let player;
let lpanel_collapsed = true;
let rpanel_collapsed = false;
let markers: Marker[] = [];
// load markers from local storage
markers = JSON.parse(
window.localStorage.getItem(`markers:${room_id}:${live_id}`) || "[]"
);
$: {
// makers changed, save to local storage
window.localStorage.setItem(
`markers:${room_id}:${live_id}`,
JSON.stringify(markers)
);
}
</script>
<main>
{#if use_titlebar}
<TitleBar dark />
{/if}
<div class="flex flex-row">
<div class="w-3/4 overflow-hidden">
<Player bind:start bind:end {port} {room_id} {ts} />
<Modal title="预览" bind:open={preview} autoclose>
<!-- svelte-ignore a11y-media-has-caption -->
<video src={video_src} controls />
</Modal>
</div>
<div class="flex flex-row overflow-hidden">
<div
class="w-1/4 h-screen overflow-hidden border-solid bg-gray-50 border-l-2 border-slate-200 z-[39]"
class="flex relative h-screen border-solid bg-gray-950 border-r-2 border-gray-800 z-[501] transition-all duration-300 ease-in-out"
class:w-[200px]={!lpanel_collapsed}
class:w-0={lpanel_collapsed}
>
<div
id="post-panel"
class="mt-6 overflow-y-auto overflow-x-hidden p-6"
class:titlebar={use_titlebar}
<div class="relative flex w-full overflow-hidden">
<div
class="w-[200px] transition-all duration-300 overflow-hidden flex-shrink-0"
style="margin-left: {lpanel_collapsed ? '-200px' : '0'};"
>
<div class="w-full whitespace-nowrap">
<MarkerPanel
{archive}
bind:markers
on:markerClick={(e) => {
player.seek(e.detail.offset);
}}
/>
</div>
</div>
</div>
<button
class="collapse-btn lp transition-transform duration-300 absolute"
on:click={() => {
lpanel_collapsed = !lpanel_collapsed;
}}
>
{#if lpanel_collapsed}
<ChevronRight class="text-white" size={20} />
{:else}
<ChevronLeft class="text-white" size={20} />
{/if}
</button>
</div>
<div class="overflow-hidden h-screen w-full relative">
<Player
bind:start
bind:end
bind:this={player}
{port}
{platform}
{room_id}
{live_id}
{markers}
on:markerAdd={(e) => {
markers.push({
offset: e.detail.offset,
realtime: e.detail.realtime,
content: "[空标记点]",
});
markers = markers.sort((a, b) => a.offset - b.offset);
}}
/>
{#if preview}
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if video}
<div
class="w-full mb-2"
on:click={() => {
preview = true;
}}
<div
class="fixed inset-0 bg-black/30 backdrop-blur-sm z-[1000] transition-opacity duration-200"
class:opacity-0={!preview}
class:opacity-100={preview}
on:click={() => preview = false}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] bg-[#1c1c1e] rounded-xl shadow-2xl overflow-hidden transition-all duration-200 scale-100"
class:opacity-0={!preview}
class:opacity-100={preview}
class:scale-95={!preview}
class:scale-100={preview}
on:click|stopPropagation
>
<div id="capture" class="cover-wrap relative cursor-pointer">
<div
class="cover-text absolute py-1 px-8"
class:play-icon={false}
<!-- 标题栏 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-800/50 bg-[#2c2c2e]">
<h3 class="text-lg font-medium text-white">预览视频</h3>
<button
class="w-6 h-6 rounded-full bg-[#ff5f57] hover:bg-[#ff5f57]/90 transition-colors duration-200 flex items-center justify-center group"
on:click={() => preview = false}
>
{cover_text}
</div>
<div class="play-icon opacity-0">
<PlayOutline class="w-full h-full absolute" color="white" />
</div>
<img src={cover} alt="cover" />
<svg class="w-3 h-3 text-[#1c1c1e] opacity-0 group-hover:opacity-100 transition-opacity duration-200" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<!-- 视频容器 -->
<div class="relative aspect-video bg-black">
<!-- svelte-ignore a11y-media-has-caption -->
<video
src={selected_video?.file}
controls
class="w-full h-full"
/>
</div>
</div>
{/if}
<div class="w-full flex flex-col justify-center">
<Label>切片列表</Label>
<Select
items={videos}
bind:value={video_selected}
on:change={find_video}
class="mb-2"
/>
<ButtonGroup>
<Button on:click={generate_clip} disabled={loading} color="primary">
{#if loading}
<Spinner class="me-3" size="4" />
{:else}
<ClapperboardPlaySolid />
{/if}
从选区生成新切片</Button
>
<Button
color="red"
disabled={!loading && !video}
on:click={delete_video}>删除</Button
>
</ButtonGroup>
</div>
<Hr />
<Label class="mt-4">标题</Label>
<Input
size="sm"
bind:value={profile.title}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">封面文本</Label>
<Textarea bind:value={cover_text} />
<Label class="mt-2">描述</Label>
<Textarea
bind:value={profile.desc}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">标签</Label>
<Input
size="sm"
bind:value={profile.tag}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">动态</Label>
<Textarea
bind:value={profile.dynamic}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">视频分区</Label>
<Input size="sm" value="动画 - 综合" disabled />
<Label class="mt-2">投稿账号</Label>
<Select size="sm" items={accounts} bind:value={uid_selected} />
{#if video}
<div class="flex mt-4 justify-center w-full">
<Button on:click={do_post} disabled={loading}>
{/if}
</div>
<div
class="flex relative h-screen border-solid bg-gray-950 border-l-2 border-gray-800 text-white transition-all duration-300 ease-in-out"
class:w-[400px]={!rpanel_collapsed}
class:w-0={rpanel_collapsed}
>
<button
class="collapse-btn rp transition-transform duration-300"
class:translate-x-[-20px]={!rpanel_collapsed}
class:translate-x-0={rpanel_collapsed}
on:click={() => {
rpanel_collapsed = !rpanel_collapsed;
}}
>
{#if rpanel_collapsed}
<ChevronLeft class="text-white" size={20} />
{:else}
<ChevronRight class="text-white" size={20} />
{/if}
</button>
<div
id="post-panel"
class="h-screen bg-[#1c1c1e] text-white w-[400px] flex flex-col transition-opacity duration-300"
class:opacity-0={rpanel_collapsed}
class:opacity-100={!rpanel_collapsed}
class:invisible={rpanel_collapsed}
>
<!-- 顶部标题栏 -->
<div
class="flex-none sticky top-0 z-10 backdrop-blur-xl bg-[#1c1c1e]/80 px-6 py-4 border-b border-gray-800/50"
>
<h2 class="text-lg font-medium">视频投稿</h2>
</div>
<!-- 内容区域 -->
<div class="flex-1 overflow-y-auto">
<div class="px-6 py-4 space-y-8">
<!-- 切片操作区 -->
<section class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">切片列表</h3>
<div class="flex space-x-2">
<button
on:click={generate_clip}
disabled={loading}
class="px-4 py-1.5 bg-[#0A84FF] text-white text-sm rounded-lg
transition-all duration-200 hover:bg-[#0A84FF]/90
disabled:opacity-50 disabled:cursor-not-allowed
flex items-center space-x-2"
>
{#if loading}
<div
class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"
/>
{/if}
<span>生成切片</span>
</button>
{#if selected_video}
<button
on:click={delete_video}
disabled={loading}
class="px-4 py-1.5 text-red-500 text-sm rounded-lg
transition-all duration-200 hover:bg-red-500/10
disabled:opacity-50 disabled:cursor-not-allowed"
>
删除
</button>
{/if}
</div>
</div>
<select
bind:value={video_selected}
on:change={find_video}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none appearance-none
hover:border-gray-700/50"
>
<option value={0}>选择切片</option>
{#each videos as video}
<option value={video.value}>{video.name}</option>
{/each}
</select>
</section>
<!-- 封面预览 -->
{#if selected_video && selected_video.id != -1}
<section>
<div class="group">
<div class="text-sm text-gray-400 mb-2 flex items-center justify-between">
<span>视频封面</span>
<button
class="text-[#0A84FF] hover:text-[#0A84FF]/80 transition-colors duration-200 flex items-center space-x-1"
on:click={() => show_cover_editor = true}
>
<Pen class="w-4 h-4" />
<span class="text-xs">创建新封面</span>
</button>
</div>
<div
id="capture"
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50 cursor-pointer group"
on:click={() => preview = true}
>
<div
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100
transition duration-200 flex items-center justify-center backdrop-blur-[2px]"
>
<div
class="bg-white/10 backdrop-blur p-3 rounded-full opacity-0 group-hover:opacity-50"
>
<Play class="w-6 h-6 text-white" />
</div>
</div>
<img src={selected_video.cover} alt="视频封面" class="w-full" />
</div>
</div>
</section>
{/if}
<!-- 表单区域 -->
<section class="space-y-8">
<!-- 基本信息 -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-400">基本信息</h3>
<!-- 标题 -->
<div class="space-y-2">
<label for="title" class="block text-sm font-medium text-gray-300"
>标题</label
>
<input
id="title"
type="text"
bind:value={profile.title}
placeholder="输入视频标题"
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none
hover:border-gray-700/50"
/>
</div>
<!-- 视频分区 -->
<div class="space-y-2">
<label for="tid" class="block text-sm font-medium text-gray-300"
>视频分区</label
>
<div class="w-full" id="tid">
<TypeSelect bind:value={profile.tid} />
</div>
</div>
<!-- 投稿账号 -->
<div id="uid" class="space-y-2">
<label for="uid" class="block text-sm font-medium text-gray-300"
>投稿账号</label
>
<select
bind:value={uid_selected}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none appearance-none
hover:border-gray-700/50"
>
{#each accounts as account}
<option value={account.value}>{account.name}</option>
{/each}
</select>
</div>
</div>
<!-- 详细信息 -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-400">详细信息</h3>
<!-- 描述 -->
<div class="space-y-2">
<label for="desc" class="block text-sm font-medium text-gray-300"
>描述</label
>
<textarea
id="desc"
bind:value={profile.desc}
placeholder="输入视频描述"
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none resize-none h-32
hover:border-gray-700/50"
/>
</div>
<!-- 标签 -->
<div class="space-y-2">
<label for="tag" class="block text-sm font-medium text-gray-300"
>标签</label
>
<input
id="tag"
type="text"
bind:value={profile.tag}
placeholder="输入视频标签,用逗号分隔"
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none
hover:border-gray-700/50"
/>
</div>
<!-- 动态 -->
<div class="space-y-2">
<label for="dynamic" class="block text-sm font-medium text-gray-300"
>动态</label
>
<textarea
id="dynamic"
bind:value={profile.dynamic}
placeholder="输入动态内容"
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none resize-none h-32
hover:border-gray-700/50"
/>
</div>
</div>
</section>
<!-- 投稿按钮 -->
{#if selected_video}
<div class="h-10" />
{/if}
</div>
</div>
<!-- 底部按钮 -->
{#if selected_video}
<div
class="flex-none sticky bottom-0 px-6 py-4 bg-gradient-to-t from-[#1c1c1e] via-[#1c1c1e] to-transparent"
>
<button
on:click={do_post}
disabled={loading}
class="w-full px-4 py-2.5 bg-[#0A84FF] text-white rounded-lg
transition-all duration-200 hover:bg-[#0A84FF]/90
disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center space-x-2"
>
{#if loading}
<Spinner class="me-3" size="4" />
<div
class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"
/>
{/if}
投稿
</Button>
<span>投稿</span>
</button>
</div>
{/if}
</div>
@@ -386,31 +606,44 @@
</div>
</main>
<CoverEditor
bind:show={show_cover_editor}
video={selected_video}
on:coverUpdate={(event) => {
selected_video = {
...selected_video,
cover: event.detail.cover
};
}}
/>
<style>
main {
width: 100vw;
height: 100vh;
}
.cover-wrap:hover {
opacity: 0.8;
.collapse-btn {
position: absolute;
z-index: 50;
top: 50%;
width: 20px;
height: 40px;
}
.cover-wrap:hover .play-icon {
opacity: 0.5;
.collapse-btn.rp {
left: -20px;
border-radius: 4px 0 0 4px;
border: 2px solid rgb(31 41 55 / var(--tw-border-opacity));
border-right: none;
background-color: rgb(3 7 18 / var(--tw-bg-opacity));
transform: translateY(-50%);
}
.cover-text {
white-space: pre-wrap;
font-size: 24px;
line-height: 1.3;
font-weight: bold;
color: rgb(255, 127, 0);
text-shadow:
-1px -1px 0 rgba(255, 255, 255, 1),
1px -1px 0 rgba(255, 255, 255, 1),
-1px 1px 0 rgba(255, 255, 255, 1),
1px 1px 0 rgba(255, 255, 255, 1),
-2px -2px 0 rgba(255, 255, 255, 0.5),
2px -2px 0 rgba(255, 255, 255, 0.5),
-2px 2px 0 rgba(255, 255, 255, 0.5),
2px 2px 0 rgba(255, 255, 255, 0.5); /* 创建细腻的白色描边效果 */
.collapse-btn.lp {
right: -20px;
border-radius: 0 4px 4px 0;
border: 2px solid rgb(31 41 55 / var(--tw-border-opacity));
border-left: none;
background-color: rgb(3 7 18 / var(--tw-bg-opacity));
transform: translateY(-50%);
}
</style>

View File

@@ -1,43 +0,0 @@
<script type="ts">
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { Card } from "flowbite-svelte";
const appWindow = getCurrentWebviewWindow();
let version = "";
getVersion().then((v) => {
version = v;
appWindow.setTitle(`BiliBili ShadowReplay - v${version}`);
console.log(version);
});
let latest_version = "";
// get lastest version from github release api
fetch("https://api.github.com/repos/Xinrea/bili-shadowreplay/releases/latest")
.then((response) => response.json())
.then((data) => {
latest_version = data.tag_name;
});
</script>
<div class="p-8 pt-12 h-full overflow-auto">
<Card size="lg">
<h1 class="text-2xl font-bold">关于</h1>
<p>
BiliBili ShadowReplay 是一个用于实时查看和剪辑 B
站直播流,并生成视频投稿的工具。
</p>
<p class="mt-4">
项目地址: <a
target="_blank"
href="https://github.com/Xinrea/bili-shadowreplay"
>https://github.com/Xinrea/bili-shadowreplay</a
>
</p>
<p>作者: Xinrea</p>
<p>
当前版本: v{version}
</p>
<p>
最新版本: {latest_version}
</p>
</Card>
</div>

View File

@@ -1,185 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
Button,
Card,
Table,
TableHead,
TableHeadCell,
TableBody,
TableBodyRow,
TableBodyCell,
Modal,
ButtonGroup,
SpeedDial,
Listgroup,
ListgroupItem,
Textarea,
Hr,
} from "flowbite-svelte";
import Image from "./Image.svelte";
import QRCode from "qrcode";
import type { AccountItem, AccountInfo } from "./db";
import { PlusOutline, UserAddSolid } from "flowbite-svelte-icons";
let account_info: AccountInfo = {
primary_uid: 0,
accounts: [],
};
async function update_accounts() {
account_info = await invoke("get_accounts");
}
update_accounts();
let addModal = false;
let oauth_key = "";
let check_interval = null;
let manualModal = false;
let cookie_str = "";
async function handle_qr() {
if (check_interval) {
clearInterval(check_interval);
}
let qr_info: { url: string; oauthKey: string } = await invoke("get_qr");
oauth_key = qr_info.oauthKey;
const canvas = document.getElementById("qr");
QRCode.toCanvas(canvas, qr_info.url, function (error) {
if (error) {
console.log(error);
return;
}
canvas.style.display = "block";
check_interval = setInterval(check_qr, 2000);
});
}
async function check_qr() {
let qr_status: { code: number; cookies: string } = await invoke(
"get_qr_status",
{ qrcodeKey: oauth_key },
);
if (qr_status.code == 0) {
clearInterval(check_interval);
await invoke("add_account", { cookies: qr_status.cookies });
await update_accounts();
addModal = false;
}
}
async function add_cookie() {
if (cookie_str == "") {
return;
}
try {
await invoke("add_account", { cookies: cookie_str });
await update_accounts();
cookie_str = "";
manualModal = false;
} catch (e) {
alert("Err adding cookie:" + e);
}
}
</script>
<div class="p-8 pt-12 h-full overflow-auto">
<Table hoverable={true} divClass="relative max-h-full" shadow>
<TableHead>
<TableHeadCell>UID</TableHeadCell>
<TableHeadCell>头像</TableHeadCell>
<TableHeadCell>用户名</TableHeadCell>
<TableHeadCell>状态</TableHeadCell>
<TableHeadCell>添加时间</TableHeadCell>
<TableHeadCell>操作</TableHeadCell>
</TableHead>
<TableBody tableBodyClass="divide-y">
{#each account_info.accounts as account}
<TableBodyRow>
<TableBodyCell>{account.uid}</TableBodyCell>
<TableBodyCell
><Image
iclass="rounded-full w-12"
src={account.avatar}
/></TableBodyCell
>
<TableBodyCell>{account.name}</TableBodyCell>
<TableBodyCell
>{account.uid == account_info.primary_uid
? "主账号"
: "普通账号"}</TableBodyCell
>
<TableBodyCell
>{new Date(account.created_at).toLocaleString()}</TableBodyCell
>
<TableBodyCell>
<ButtonGroup>
<Button
on:click={async () => {
await invoke("remove_account", { uid: account.uid });
await update_accounts();
}}>注销</Button
>
{#if account.uid != account_info.primary_uid}
<Button
on:click={async () => {
await invoke("set_primary", { uid: account.uid });
await update_accounts();
}}>设置为主账号</Button
>
{/if}
</ButtonGroup></TableBodyCell
>
</TableBodyRow>
{/each}
</TableBody>
</Table>
</div>
<SpeedDial defaultClass="absolute end-6 bottom-6" placement="top-end">
<Listgroup active>
<ListgroupItem
class="flex gap-2 md:px-5"
on:click={() => {
addModal = true;
requestAnimationFrame(handle_qr);
}}>扫码添加</ListgroupItem
>
<ListgroupItem
class="flex gap-2 md:px-5"
on:click={() => {
manualModal = true;
}}>手动添加</ListgroupItem
>
</Listgroup>
</SpeedDial>
<Modal
title="请使用 BiliBili App 扫码登录"
bind:open={addModal}
size="sm"
autoclose
>
<div class="flex justify-center items-center h-full">
<canvas id="qr" />
</div>
</Modal>
<Modal
title="请粘贴 BiliBili 账号 Cookie"
bind:open={manualModal}
size="sm"
autoclose
>
<div class="flex flex-col justify-center items-center h-full">
<Textarea bind:value={cookie_str} />
<Button
class="mt-4"
on:click={() => {
add_cookie();
}}>添加</Button
>
</div>
</Modal>

View File

@@ -1,152 +1,68 @@
<script>
import {
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
} from "flowbite-svelte";
import {
ChartPieSolid,
GridSolid,
MailBoxSolid,
UserSolid,
EditOutline,
BookSolid,
InfoCircleSolid,
CogSolid,
} from "flowbite-svelte-icons";
let spanClass = "flex-1 ms-3 whitespace-nowrap";
// acitveUrl is shared between project
export let activeUrl = "#总览";
export let room_count = 0;
export let message_cnt = 0;
import { Info, LayoutDashboard, Settings, Users, Video, FileVideo } from "lucide-svelte";
// acitveUrl is shared between project
export let activeUrl = "#总览";
function navigate(route) {
activeUrl = route;
}
</script>
<Sidebar {activeUrl} asideClass="w-72 h-full z-[40]">
<SidebarWrapper divClass="overflow-y-auto py-4 px-3 bg-gray-50 h-full">
<SidebarGroup>
<SidebarItem
label="总览"
href="#"
on:click={() => {
activeUrl = "#总览";
}}
>
<svelte:fragment slot="icon">
<ChartPieSolid
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
</svelte:fragment>
</SidebarItem>
<SidebarItem
label="直播间"
href="#"
on:click={() => {
activeUrl = "#直播间";
}}
{spanClass}
>
<svelte:fragment slot="icon">
<GridSolid
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
</svelte:fragment>
<svelte:fragment slot="subtext">
<span
class="inline-flex justify-center items-center px-2 ms-3 text-sm font-medium text-gray-800 bg-gray-200 rounded-full dark:bg-gray-700 dark:text-gray-300"
>
{room_count}
</span>
</svelte:fragment>
</SidebarItem>
<SidebarItem
label="消息"
href="#"
on:click={() => {
activeUrl = "#消息";
}}
{spanClass}
>
<svelte:fragment slot="icon">
<MailBoxSolid
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
</svelte:fragment>
<svelte:fragment slot="subtext">
<span
class="inline-flex justify-center items-center p-3 ms-3 w-3 h-3 text-sm font-medium text-primary-600 bg-primary-200 rounded-full dark:bg-primary-900 dark:text-primary-200"
>
{message_cnt}
</span>
</svelte:fragment>
</SidebarItem>
<SidebarItem
label="账号"
href="#"
on:click={() => {
activeUrl = "#账号";
}}
>
<svelte:fragment slot="icon">
<UserSolid
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
</svelte:fragment>
</SidebarItem>
<!-- <SidebarItem
label="自动化"
href="#"
on:click={() => {
activeUrl = "#自动化";
}}
>
<svelte:fragment slot="icon">
<EditOutline
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
</svelte:fragment>
</SidebarItem> -->
<SidebarItem
label="设置"
href="#"
on:click={() => {
activeUrl = "#设置";
}}
>
<svelte:fragment slot="icon">
<CogSolid
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
</svelte:fragment>
</SidebarItem>
</SidebarGroup>
<SidebarGroup border>
<SidebarItem
label="文档"
href="#"
on:click={() => {
activeUrl = "#文档";
}}
>
<svelte:fragment slot="icon">
<BookSolid
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
</svelte:fragment>
</SidebarItem>
<SidebarItem
label="关于"
href="#"
on:click={() => {
activeUrl = "#关于";
}}
>
<svelte:fragment slot="icon">
<InfoCircleSolid
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
</svelte:fragment>
</SidebarItem>
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
<style>
:global(img.text-\[\#0A84FF\]) {
filter: invert(48%) sepia(85%) saturate(2229%) hue-rotate(198deg) brightness(100%) contrast(101%);
}
:global(img.text-gray-700) {
filter: invert(23%) sepia(10%) saturate(532%) hue-rotate(182deg) brightness(94%) contrast(90%);
}
:global(.dark img.text-\[\#0A84FF\]) {
filter: invert(48%) sepia(85%) saturate(2229%) hue-rotate(198deg) brightness(100%) contrast(101%);
}
</style>
<div
class="w-48 bg-[#f0f0f3]/50 dark:bg-[#2c2c2e]/50 backdrop-blur-xl border-r border-gray-200 dark:border-gray-700"
>
<nav class="p-3 space-y-1">
<button
on:click={() => navigate("#总览")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#总览' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<LayoutDashboard
class="w-5 h-5 {activeUrl === '#总览' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}"
/>
<span>总览</span>
</button>
<button
on:click={() => navigate("#直播间")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#直播间' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<Video class="w-5 h-5 {activeUrl === '#直播间' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
<span>直播间</span>
</button>
<button
on:click={() => navigate("#账号")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#账号' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<Users class="w-5 h-5 {activeUrl === '#账号' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
<span>账号</span>
</button>
<button
on:click={() => navigate("#设置")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#设置' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<Settings class="w-5 h-5 {activeUrl === '#设置' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
<span>设置</span>
</button>
<button
on:click={() => navigate("#关于")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#关于' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<Info class="w-5 h-5 {activeUrl === '#关于' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
<span>关于</span>
</button>
</nav>
</div>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
let className = '';
export { className as class };
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="{className}">
<path fill="currentColor" d="M7.172 2.757L10.414 6h3.171l3.243-3.242a1 1 0 0 1 1.415 1.415L16.414 6H18.5A3.5 3.5 0 0 1 22 9.5v8a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 17.5v-8A3.5 3.5 0 0 1 5.5 6h2.085L5.757 4.171a1 1 0 0 1 1.415-1.414zM18.5 8h-13a1.5 1.5 0 0 0-1.493 1.356L4 9.5v8a1.5 1.5 0 0 0 1.356 1.493L5.5 19h13a1.5 1.5 0 0 0 1.493-1.356L20 17.5v-8a1.5 1.5 0 0 0-1.356-1.493L18.5 8zM8 11a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1zm8 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1z"/>
</svg>

641
src/lib/CoverEditor.svelte Normal file
View File

@@ -0,0 +1,641 @@
<script lang="ts">
import { Play, X, Type, Palette, Move, Plus, Trash2 } from "lucide-svelte";
import { invoke } from "@tauri-apps/api/core";
import { onMount, createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let video = null;
export let show: boolean = false;
// 文本列表
let texts = [
{
id: 1,
content: "",
position: { x: 50, y: 50 },
fontSize: 48,
color: "#FF7F00",
strokeColor: "#FFFFFF",
},
];
let selectedTextId = 1;
let isDragging = false;
let startPos = { x: 0, y: 0 };
let startTextPos = { x: 0, y: 0 };
let videoElement: HTMLVideoElement;
let videoFrame;
let isVideoLoaded = false;
let currentTime = 0;
let duration = 0;
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let canvasWidth = 1280;
let canvasHeight = 720;
let scale = 1;
let backgroundImage: HTMLImageElement;
let redrawRequestId: number | null = null;
let isRedrawScheduled = false;
onMount(() => {
ctx = canvas.getContext("2d");
loadBackgroundImage();
resizeCanvas();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
if (redrawRequestId !== null) {
cancelAnimationFrame(redrawRequestId);
}
};
});
function handleResize() {
if (!isRedrawScheduled) {
isRedrawScheduled = true;
redrawRequestId = requestAnimationFrame(() => {
resizeCanvas();
isRedrawScheduled = false;
});
}
}
function scheduleRedraw() {
if (!isRedrawScheduled) {
isRedrawScheduled = true;
redrawRequestId = requestAnimationFrame(() => {
redraw();
isRedrawScheduled = false;
});
}
}
function loadBackgroundImage() {
if (!videoFrame) {
return;
}
backgroundImage = new Image();
backgroundImage.crossOrigin = "anonymous";
backgroundImage.onload = () => {
scheduleRedraw();
};
backgroundImage.onerror = (e) => {
console.error("Failed to load image:", e);
};
backgroundImage.src = videoFrame;
}
function resizeCanvas() {
const container = document.getElementById("cover-container");
if (!container) return;
const rect = container.getBoundingClientRect();
scale = rect.width / canvasWidth;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
scheduleRedraw();
}
function redraw() {
if (!ctx) return;
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制背景图片
if (backgroundImage && backgroundImage.complete) {
ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
}
// 绘制所有文本
texts.forEach((text) => {
drawText(text);
});
}
function drawText(text) {
if (!ctx) return;
const x = (text.position.x / 100) * canvas.width;
const y = (text.position.y / 100) * canvas.height;
ctx.font = `bold ${text.fontSize}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// 绘制描边
ctx.strokeStyle = text.strokeColor;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.miterLimit = 2;
ctx.strokeText(text.content, x, y);
// 绘制半透明描边
ctx.strokeStyle = `${text.strokeColor}80`;
ctx.lineWidth = 6;
ctx.strokeText(text.content, x, y);
// 绘制文本
ctx.fillStyle = text.color;
ctx.fillText(text.content, x, y);
}
function handleMouseDown(event: MouseEvent) {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// 检查是否点击到文本
texts.forEach((text) => {
const textX = (text.position.x / 100) * rect.width;
const textY = (text.position.y / 100) * rect.height;
ctx.font = `bold ${text.fontSize}px sans-serif`;
const metrics = ctx.measureText(text.content);
const textWidth = metrics.width;
const textHeight = text.fontSize;
if (
x >= textX - textWidth / 2 - 10 &&
x <= textX + textWidth / 2 + 10 &&
y >= textY - textHeight / 2 - 10 &&
y <= textY + textHeight / 2 + 10
) {
isDragging = true;
selectedTextId = text.id;
startPos = { x: event.clientX, y: event.clientY };
startTextPos = { ...text.position };
}
});
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging) return;
const rect = canvas.getBoundingClientRect();
const deltaX = ((event.clientX - startPos.x) / rect.width) * 100;
const deltaY = ((event.clientY - startPos.y) / rect.height) * 100;
// 限制文本位置在画布范围内
const newX = Math.max(0, Math.min(100, startTextPos.x + deltaX));
const newY = Math.max(0, Math.min(100, startTextPos.y + deltaY));
texts = texts.map((text) => {
if (text.id === selectedTextId) {
return {
...text,
position: {
x: newX,
y: newY,
},
};
}
return text;
});
scheduleRedraw();
}
function handleMouseUp() {
if (isDragging) {
isDragging = false;
}
}
function addNewText() {
const newId = Math.max(0, ...texts.map((t) => t.id)) + 1;
texts = [
...texts,
{
id: newId,
content: "",
position: { x: 50, y: 50 },
fontSize: 48,
color: "#FF7F00",
strokeColor: "#FFFFFF",
},
];
selectedTextId = newId;
scheduleRedraw();
}
function deleteText(id: number) {
texts = texts.filter((t) => t.id !== id);
if (texts.length > 0) {
selectedTextId = texts[0].id;
}
scheduleRedraw();
}
function handleVideoLoaded() {
isVideoLoaded = true;
duration = videoElement.duration;
updateCoverFromVideo();
}
function handleTimeUpdate() {
currentTime = videoElement.currentTime;
}
function handleSeek(event: Event) {
const target = event.target as HTMLInputElement;
const time = parseFloat(target.value);
if (videoElement) {
videoElement.currentTime = time;
updateCoverFromVideo();
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function updateCoverFromVideo() {
if (!videoElement) return;
const tempCanvas = document.createElement("canvas");
tempCanvas.width = videoElement.videoWidth;
tempCanvas.height = videoElement.videoHeight;
const tempCtx = tempCanvas.getContext("2d");
tempCtx.drawImage(videoElement, 0, 0, tempCanvas.width, tempCanvas.height);
videoFrame = tempCanvas.toDataURL("image/jpeg");
loadBackgroundImage();
}
function handleClose() {
show = false;
}
async function handleSave() {
// 确保 Canvas 已完全渲染
await new Promise<void>((resolve) => {
requestAnimationFrame(async () => {
// 强制重绘一次
redraw();
// 等待一帧以确保渲染完成
requestAnimationFrame(async () => {
try {
// 直接使用 canvas 的内容作为新封面
const newCover = canvas.toDataURL("image/jpeg");
await invoke("update_video_cover", {
id: video.value,
cover: newCover,
});
// 触发自定义事件通知父组件更新封面
dispatch("coverUpdate", { cover: newCover });
handleClose();
} catch (e) {
alert("更新封面失败: " + e);
}
resolve();
});
});
});
}
function handleTextInput(text, event: Event) {
const target = event.target as HTMLTextAreaElement;
text.content = target.value;
scheduleRedraw();
}
$: {
// 当文本内容或样式改变时重绘
if (ctx) {
texts = texts.map((text) => {
if (text.id === selectedTextId) {
return {
...text,
content: text.content,
fontSize: text.fontSize,
color: text.color,
position: text.position,
};
}
return text;
});
scheduleRedraw();
}
}
$: selectedText = texts.find((t) => t.id === selectedTextId);
// 监听 show 变化,当模态框显示时重新绘制
$: if (show && ctx) {
setTimeout(() => {
loadBackgroundImage();
resizeCanvas();
}, 50);
}
</script>
<svelte:window
on:mousemove={handleMouseMove}
on:mouseup={handleMouseUp}
on:blur={() => (isDragging = false)}
on:visibilitychange={() => {
if (document.hidden) {
isDragging = false;
}
}}
/>
<!-- Modal Backdrop -->
<div
class="fixed inset-0 bg-black/30 backdrop-blur-sm z-[1000] transition-opacity duration-200"
class:opacity-0={!show}
class:opacity-100={show}
class:pointer-events-none={!show}
>
<!-- Modal Content -->
<div
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] bg-[#1c1c1e] rounded-2xl shadow-2xl overflow-hidden transition-all duration-200"
class:opacity-0={!show}
class:opacity-100={show}
class:scale-95={!show}
class:scale-100={show}
>
<!-- Header -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-gray-800/50 bg-[#2c2c2e]"
>
<h3 class="text-base font-medium text-white">编辑封面</h3>
<button
class="w-[22px] h-[22px] rounded-full bg-[#ff5f57] hover:bg-[#ff5f57]/90 transition-colors duration-200 flex items-center justify-center group"
on:click={handleClose}
>
<X
class="w-3 h-3 text-[#1c1c1e] opacity-0 group-hover:opacity-100 transition-opacity duration-200"
/>
</button>
</div>
<!-- Body -->
<div class="p-5 space-y-4">
<!-- Video Frame Selection -->
<div class="space-y-2">
<div class="text-sm text-gray-400 flex items-center justify-between">
<span class="font-medium">选择视频帧</span>
<div class="flex items-center space-x-2 text-xs">
<span>{formatTime(currentTime)}</span>
<span class="opacity-50">/</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<!-- Hidden Video Element -->
<!-- svelte-ignore a11y-media-has-caption -->
<video
bind:this={videoElement}
src={video?.file}
class="hidden"
crossorigin="anonymous"
on:loadedmetadata={handleVideoLoaded}
on:timeupdate={handleTimeUpdate}
/>
<!-- Video Controls -->
<div class="flex items-center space-x-2">
<input
type="range"
min="0"
max={duration}
step="0.1"
bind:value={currentTime}
on:input={handleSeek}
class="flex-1"
disabled={!isVideoLoaded}
/>
</div>
</div>
<!-- Cover Preview -->
<div class="space-y-2">
<div class="text-sm text-gray-400 flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="font-medium">视频封面</span>
<span class="text-xs opacity-60">(拖拽文字调整位置)</span>
</div>
</div>
<div
id="cover-container"
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50 aspect-video"
>
<canvas
bind:this={canvas}
on:mousedown={handleMouseDown}
class="w-full h-full"
/>
</div>
</div>
<!-- Text Controls -->
<div class="space-y-3">
<div class="flex items-start space-x-4">
<!-- Text List and Input -->
<div class="flex-1 space-y-3">
<!-- Text List -->
<div class="flex items-center justify-between">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label
class="flex items-center space-x-2 text-sm font-medium text-gray-300"
>
<Type class="w-4 h-4" />
<span>文字列表</span>
</label>
<button
on:click={addNewText}
class="p-1.5 rounded-lg bg-[#2c2c2e] hover:bg-[#3c3c3e] transition-colors duration-200 text-white"
>
<Plus class="w-4 h-4" />
</button>
</div>
<div class="space-y-1.5">
{#each texts as text (text.id)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="flex items-center space-x-2 p-2 rounded-lg transition-colors duration-200 cursor-pointer"
class:bg-[#2c2c2e]={selectedTextId === text.id}
on:click={() => (selectedTextId = text.id)}
>
<textarea
value={text.content}
on:input={(e) => handleTextInput(text, e)}
placeholder="输入文字内容"
class="flex-1 bg-transparent text-white text-sm outline-none resize-none placeholder:text-gray-500"
/>
{#if texts.length > 1}
<button
on:click={() => deleteText(text.id)}
class="p-1 rounded hover:bg-[#3c3c3e] transition-colors duration-200 text-red-500"
>
<Trash2 class="w-4 h-4" />
</button>
{/if}
</div>
{/each}
</div>
</div>
<!-- Text Style Controls -->
{#if selectedText}
<div class="w-48 space-y-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label
class="flex items-center space-x-2 text-sm font-medium text-gray-300"
>
<Palette class="w-4 h-4" />
<span>文字样式</span>
</label>
<div
class="space-y-3 p-2.5 rounded-lg bg-[#2c2c2e] border border-gray-800/50"
>
<!-- Font Size -->
<div class="space-y-1">
<label
for="fontSize"
class="text-xs text-gray-400 font-medium">字体大小</label
>
<input
id="fontSize"
type="range"
bind:value={selectedText.fontSize}
min="48"
max="160"
class="w-full"
/>
</div>
<!-- Colors -->
<div class="grid grid-cols-2 gap-2">
<!-- Text Color -->
<div class="space-y-1">
<label
for="textColor"
class="text-xs text-gray-400 font-medium">文字颜色</label
>
<input
id="textColor"
type="color"
bind:value={selectedText.color}
class="w-full h-7 rounded-lg cursor-pointer"
/>
</div>
<!-- Stroke Color -->
<div class="space-y-1">
<label
for="strokeColor"
class="text-xs text-gray-400 font-medium">描边颜色</label
>
<input
id="strokeColor"
type="color"
bind:value={selectedText.strokeColor}
class="w-full h-7 rounded-lg cursor-pointer"
/>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Footer -->
<div
class="px-5 py-3 border-t border-gray-800/50 flex justify-end space-x-3"
>
<button
class="px-4 py-1.5 text-gray-400 hover:text-white transition-colors duration-200 text-sm font-medium"
on:click={handleClose}
>
取消
</button>
<button
class="px-4 py-1.5 bg-[#0A84FF] text-white rounded-lg hover:bg-[#0A84FF]/90 transition-colors duration-200 text-sm font-medium"
on:click={handleSave}
>
确定
</button>
</div>
</div>
</div>
<style>
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
height: 24px;
margin: -8px 0;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
background: #4a4a4a;
border-radius: 2px;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #0a84ff;
margin-top: -6px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease;
}
input[type="range"]:hover::-webkit-slider-thumb {
transform: scale(1.1);
}
input[type="range"]:active::-webkit-slider-thumb {
transform: scale(0.95);
background: #0A84FF/90;
}
input[type="range"]:focus {
outline: none;
}
input[type="color"] {
-webkit-appearance: none;
appearance: none;
border: none;
padding: 0;
background: transparent;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 6px;
}
textarea {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
}
textarea::placeholder {
color: rgba(255, 255, 255, 0.3);
}
</style>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
let className = '';
export { className as class };
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class={className}>
<path fill="currentColor" d="M22.5 9.84202C20.4357 9.84696 18.4221 9.20321 16.7435 8.00171V16.3813C16.7429 17.9333 16.2685 19.4482 15.3838 20.7233C14.499 21.9984 13.246 22.973 11.7923 23.5168C10.3387 24.0606 8.75362 24.1477 7.24914 23.7664C5.74466 23.3851 4.39245 22.5536 3.37333 21.3787C2.3542 20.2037 1.71674 18.7397 1.54617 17.1913C1.3756 15.6429 1.68007 14.0803 2.41884 12.7023C3.15762 11.3244 4.2942 10.1891 5.68563 9.43829C7.07704 8.68749 8.65658 8.35815 10.2285 8.48908V12.8205C9.45028 12.5892 8.61144 12.633 7.86093 12.9452C7.11043 13.2574 6.49034 13.8208 6.09024 14.5447C5.69013 15.2686 5.53293 16.1105 5.64381 16.9409C5.75469 17.7714 6.12761 18.5399 6.70443 19.1393C7.28125 19.7387 8.02641 20.1352 8.84547 20.2664C9.66452 20.3976 10.5066 20.2568 11.2403 19.8658C11.974 19.4749 12.5556 18.8558 12.8932 18.0975C13.2308 17.3392 13.3049 16.4943 13.1033 15.6896V0H16.7429C16.7429 0.277113 16.7449 0.554225 16.7489 0.826725C16.7865 2.47069 17.3159 4.05778 18.2582 5.37345C19.2005 6.68912 20.5055 7.66808 22.0009 8.16951C22.1665 8.22944 22.3337 8.28805 22.5 8.34202V9.84202Z"/>
</svg>

View File

@@ -5,13 +5,11 @@
let b = "";
async function getImage(url: string) {
if (!url) {
return "";
return "/imgs/douyin.png";
}
const response = await fetch(url, {
method: "GET",
});
console.log(response.status); // e.g. 200
console.log(response.statusText); // e.g. "OK"
return URL.createObjectURL(await response.blob());
}
async function init() {

144
src/lib/MarkerPanel.svelte Normal file
View File

@@ -0,0 +1,144 @@
<script lang="ts">
import {
BanOutline,
CloseOutline,
ForwardOutline,
ClockOutline,
} from "flowbite-svelte-icons";
import type { Marker } from "./interface";
import { createEventDispatcher } from "svelte";
import { Tooltip } from "flowbite-svelte";
import { invoke } from "@tauri-apps/api/core";
import { save } from "@tauri-apps/plugin-dialog";
import type { RecordItem } from "./db";
const dispatch = createEventDispatcher();
export let archive: RecordItem;
export let markers: Marker[] = [];
let realtime = false;
function format_duration(duration: number) {
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
function format_realtime(ts: number) {
const d = new Date(ts * 1000);
return d.toLocaleString();
}
function dispatch_markerclick(marker: Marker) {
dispatch("markerClick", marker);
}
async function export_to_file() {
let r = "# 由 BiliShadowReplay 自动生成\n";
r += `# ${archive.title} - 直播开始时间:${format_realtime(archive.live_id * 1000)}\n\n`;
for (let i in markers) {
r += `[${format_realtime(markers[i].realtime)}][${format_duration(markers[i].offset)}] ${
markers[i].content
}\n`;
}
let file_name = `[${archive.room_id}][${format_realtime(archive.live_id)
.split(" ")[0]
.replaceAll("/", "-")}]${archive.title}.txt`;
console.log("export to file", file_name);
const path = await save({
title: "导出标记列表",
defaultPath: file_name,
});
if (!path) return;
await invoke("export_to_file", { fileName: path, content: r });
}
</script>
<div class="flex flex-col w-full h-screen text-white p-4 pr-0">
<div class="mb-4 flex flex-row justify-between">
<div class="flex">
<span class="mr-1">标记列表</span>
<button
class="mr-1"
on:click={() => {
realtime = !realtime;
}}><ClockOutline /></button
>
<Tooltip>切换时间形式</Tooltip>
<button on:click={export_to_file}><ForwardOutline /></button>
<Tooltip>导出为文件</Tooltip>
</div>
<button
class="mr-2"
on:click={() => {
markers = [];
}}><BanOutline /></button
>
<Tooltip>清空</Tooltip>
</div>
<div class="overflow-y-auto">
{#each markers as marker, i}
<div class="marker-entry">
<div class="marker-control">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="offset"
on:click={() => {
dispatch_markerclick(marker);
}}
>{realtime
? format_realtime(marker.realtime)
: format_duration(marker.offset)}</span
>
<button
class="hover:bg-red-900"
on:click={() => {
// remove this entry
markers = markers.filter((_, idx) => idx !== i);
}}><CloseOutline /></button
>
</div>
<input
class="content w-full"
bind:value={marker.content}
on:change={(v) => {
if (marker.content == "") {
marker.content = "[空标记点]";
}
}}
/>
</div>
{/each}
</div>
</div>
<style>
.marker-entry {
display: flex;
flex-direction: column;
padding: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.marker-entry:first-child {
border-top: none;
}
.marker-entry:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.marker-entry .offset {
font-style: italic;
cursor: pointer;
margin-right: 6px;
color: rgba(255, 255, 255, 0.5);
}
.marker-entry .content {
background: transparent;
}
.marker-control {
display: flex;
padding-right: 4px;
flex-direction: row;
justify-content: space-between;
}
</style>

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
Table,
TableBody,
TableBodyRow,
TableBodyCell,
Button,
} from "flowbite-svelte";
import type { MessageItem } from "./db";
import { CloseCircleSolid, InfoCircleSolid } from "flowbite-svelte-icons";
export let message_cnt = 0;
let messages: MessageItem[] = [];
async function update() {
messages = ((await invoke("get_messages")) as MessageItem[]).sort(
(a, b) => b.id - a.id
);
message_cnt = messages.length;
}
update();
setInterval(update, 1000);
async function delete_message(id: number) {
await invoke("delete_message", { id: id });
await update();
}
</script>
<div class="p-8 pt-12 h-full overflow-hidden">
<Table hoverable={true} divClass="relative max-h-full overflow-auto" shadow>
<TableBody tableBodyClass="divide-y">
{#each messages as message}
<TableBodyRow>
<TableBodyCell tdClass="pl-6 py-4 text-center">
<InfoCircleSolid class="w-8 h-8" />
</TableBodyCell>
<TableBodyCell tdClass="text-wrap px-6 py-4">
<p class="text-lg font-bold">{message.title}</p>
<p class="text-slate-500">{message.content}</p>
</TableBodyCell>
<TableBodyCell tdClass="px-6 py-4 text-end"
><p class="text-slate-400">
{new Date(message.created_at).toLocaleString()}
</p></TableBodyCell
>
<TableBodyCell tdClass="px-6 py-4 text-end">
<Button
class="!p-2"
size="sm"
color="red"
on:click={async () => {
await delete_message(message.id);
}}
>
<CloseCircleSolid />
</Button>
</TableBodyCell>
</TableBodyRow>
{/each}
{#if messages.length == 0}
<TableBodyRow>
<TableBodyCell tdClass="pl-6 py-4 text-center" colspan="4">
<p class="text-slate-400 text-lg">暂无消息</p>
</TableBodyCell>
</TableBodyRow>
{/if}
</TableBody>
</Table>
</div>

View File

@@ -1,24 +1,39 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { AccountInfo, AccountItem } from "./db";
import type { AccountInfo } from "./db";
import type { Marker, RecorderList, RecorderInfo } from "./interface";
import { createEventDispatcher } from "svelte";
import { GridOutline, SortHorizontalOutline } from "flowbite-svelte-icons";
const dispatch = createEventDispatcher();
interface DanmuEntry {
ts: number;
content: string;
}
export let port;
export let room_id;
export let ts;
export let port: number;
export let platform: string;
export let room_id: number;
export let live_id: string;
export let start = 0;
export let end = 0;
export let markers: Marker[] = [];
export function seek(offset: number) {
video.currentTime = offset;
}
let video: HTMLVideoElement;
let show_detail = false;
let show_list = false;
let global_offset = 0;
let recorders: RecorderInfo[] = [];
// TODO get custom tag from shaka player instead of manual parsing
async function meta_parse() {
fetch(`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`)
fetch(
`http://127.0.0.1:${port}/${platform}/${room_id}/${live_id}/playlist.m3u8`
)
.then((response) => response.text())
.then((m3u8Content) => {
const offsetRegex = /#EXT-X-OFFSET:(\d+)/;
@@ -35,8 +50,27 @@
});
}
async function update_stream_list() {
recorders = (
(await invoke("get_recorder_list")) as RecorderList
).recorders.filter((r) => r.live_status && r.room_id != room_id);
}
function go_to(platform: string, room_id: number, live_id: string) {
const url = `${window.location.origin}${window.location.pathname}?port=${port}&platform=${platform}&room_id=${room_id}&live_id=${live_id}`;
window.location.href = url;
}
async function init() {
const video = document.getElementById("video") as HTMLVideoElement;
await meta_parse();
update_stream_list();
setInterval(async () => {
await update_stream_list();
}, 5 * 1000);
video = document.getElementById("video") as HTMLVideoElement;
const ui = video["ui"];
const controls = ui.getControls();
const player = controls.getPlayer();
@@ -53,8 +87,15 @@
(window as any).player = player;
(window as any).ui = ui;
player.configure({
streaming: {
lowLatencyMode: true,
},
});
player.addEventListener("ended", async () => {
location.reload();
// prevent endless reload
setTimeout(location.reload, 3 * 1000);
});
player.addEventListener("manifestloaded", (event) => {
console.log("Manifest loaded:", event);
@@ -62,7 +103,7 @@
try {
await player.load(
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`,
`http://127.0.0.1:${port}/${platform}/${room_id}/${live_id}/playlist.m3u8`
);
// This runs if the asynchronous load is successful.
console.log("The video has now been loaded!");
@@ -90,7 +131,7 @@
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
const shakaBottomControls = document.querySelector(
".shaka-bottom-controls.shaka-no-propagation",
".shaka-bottom-controls.shaka-no-propagation"
);
const selfSeekbar = document.createElement("div");
selfSeekbar.className = "shaka-seek-bar shaka-no-propagation";
@@ -113,179 +154,191 @@
// get danmaku record
let danmu_records: DanmuEntry[] = (await invoke("get_danmu_record", {
roomId: room_id,
ts: ts,
liveId: live_id,
platform: platform,
})) as DanmuEntry[];
console.log("danmu loaded:", danmu_records.length);
// history danmaku sender
setInterval(() => {
if (video.paused) {
return;
}
if (danmu_records.length == 0) {
return;
}
// using live source
if (isLive() && get_total() - video.currentTime <= 5) {
return;
}
const cur = Math.floor(
(video.currentTime + global_offset / 1000 + ts) * 1000,
);
console.log(new Date(cur).toString());
let danmus = danmu_records.filter((v) => {
return v.ts >= cur - 1000 && v.ts < cur;
});
danmus.forEach((v) => danmu_handler(v.content));
}, 1000);
let ts = parseInt(live_id);
if (isLive()) {
// add a account select
const accountSelect = document.createElement("select");
accountSelect.style.height = "30px";
accountSelect.style.minWidth = "100px";
accountSelect.style.backgroundColor = "rgba(0, 0, 0, 0)";
accountSelect.style.color = "white";
accountSelect.style.border = "1px solid gray";
accountSelect.style.padding = "0 10px";
accountSelect.style.boxSizing = "border-box";
accountSelect.style.fontSize = "1em";
// get accounts from tauri
const account_info = (await invoke("get_accounts")) as AccountInfo;
account_info.accounts.forEach((account) => {
const option = document.createElement("option");
option.value = account.uid.toString();
option.text = account.name;
accountSelect.appendChild(option);
});
// add a danmaku send input
const danmakuInput = document.createElement("input");
danmakuInput.type = "text";
danmakuInput.placeholder = "回车发送弹幕";
danmakuInput.style.width = "50%";
danmakuInput.style.height = "30px";
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
danmakuInput.style.color = "white";
danmakuInput.style.border = "1px solid gray";
danmakuInput.style.padding = "0 10px";
danmakuInput.style.boxSizing = "border-box";
danmakuInput.style.fontSize = "1em";
danmakuInput.addEventListener("keydown", async (e) => {
if (e.key === "Enter") {
const value = danmakuInput.value;
if (value) {
// get account uid from select
const uid = parseInt(accountSelect.value);
await invoke("send_danmaku", {
uid,
roomId: room_id,
ts,
message: value,
});
danmakuInput.value = "";
}
}
});
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
// listen to danmaku event
listen("danmu:" + room_id, (event: { payload: DanmuEntry }) => {
// add into records
danmu_records.push(event.payload);
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
if (platform == "bilibili") {
// history danmaku sender
setInterval(() => {
if (video.paused) {
return;
}
danmu_handler(event.payload.content);
});
}
if (danmu_records.length == 0) {
return;
}
// using live source
if (isLive() && get_total() - video.currentTime <= 5) {
return;
}
const cur = Math.floor((video.currentTime + global_offset + ts) * 1000);
// create a danmaku toggle button
const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启";
danmakuToggle.style.height = "30px";
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
danmakuToggle.style.color = "white";
danmakuToggle.style.border = "1px solid gray";
danmakuToggle.style.padding = "0 10px";
danmakuToggle.style.boxSizing = "border-box";
danmakuToggle.style.fontSize = "1em";
danmakuToggle.addEventListener("click", async () => {
danmu_enabled = !danmu_enabled;
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
// clear background color
danmakuToggle.style.backgroundColor = danmu_enabled
? "rgba(0, 128, 255, 0.5)"
: "rgba(255, 0, 0, 0.5)";
});
let danmus = danmu_records.filter((v) => {
return v.ts >= cur - 1000 && v.ts < cur;
});
danmus.forEach((v) => danmu_handler(v.content));
}, 1000);
// create a area that overlay half top of the video, which shows danmakus floating from right to left
const overlay = document.createElement("div");
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.position = "absolute";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
overlay.style.paddingTop = "10%";
// place overlay to the top of the video
video.parentElement.appendChild(overlay);
if (isLive()) {
// add a account select
const accountSelect = document.createElement("select");
accountSelect.style.height = "30px";
accountSelect.style.minWidth = "100px";
accountSelect.style.backgroundColor = "rgba(0, 0, 0, 0)";
accountSelect.style.color = "white";
accountSelect.style.border = "1px solid gray";
accountSelect.style.padding = "0 10px";
accountSelect.style.boxSizing = "border-box";
accountSelect.style.fontSize = "1em";
// Store the positions of the last few danmakus to avoid overlap
const danmakuPositions = [];
// get accounts from tauri
const account_info = (await invoke("get_accounts")) as AccountInfo;
account_info.accounts.forEach((account) => {
if (account.platform !== "bilibili") {
return;
}
const option = document.createElement("option");
option.value = account.uid.toString();
option.text = account.name;
accountSelect.appendChild(option);
});
// add a danmaku send input
const danmakuInput = document.createElement("input");
danmakuInput.type = "text";
danmakuInput.placeholder = "回车发送弹幕";
danmakuInput.style.width = "30%";
danmakuInput.style.height = "30px";
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
danmakuInput.style.color = "white";
danmakuInput.style.border = "1px solid gray";
danmakuInput.style.padding = "0 10px";
danmakuInput.style.boxSizing = "border-box";
danmakuInput.style.fontSize = "1em";
danmakuInput.addEventListener("keydown", async (e) => {
if (e.key === "Enter") {
const value = danmakuInput.value;
if (value) {
// get account uid from select
const uid = parseInt(accountSelect.value);
await invoke("send_danmaku", {
uid,
roomId: room_id,
ts,
message: value,
});
danmakuInput.value = "";
}
}
});
function danmu_handler(content: string) {
const danmaku = document.createElement("p");
danmaku.style.position = "absolute";
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
// Calculate a random position for the danmaku
let topPosition;
let attempts = 0;
do {
topPosition = Math.random() * 30;
attempts++;
} while (
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
attempts < 10
);
// Record the position
danmakuPositions.push(topPosition);
if (danmakuPositions.length > 10) {
danmakuPositions.shift(); // Keep the last 10 positions
// listen to danmaku event
const unlisten = await listen(
"danmu:" + room_id,
(event: { payload: DanmuEntry }) => {
// add into records
danmu_records.push(event.payload);
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
return;
}
danmu_handler(event.payload.content);
}
);
window.onbeforeunload = () => {
unlisten();
};
}
danmaku.style.top = `${topPosition}%`;
danmaku.style.right = "0";
danmaku.style.color = "white";
danmaku.style.fontSize = "1.2em";
danmaku.style.whiteSpace = "nowrap";
danmaku.style.transform = "translateX(100%)";
danmaku.style.transition = "transform 10s linear";
danmaku.style.pointerEvents = "none";
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = content;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
// create a danmaku toggle button
const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启";
danmakuToggle.style.height = "30px";
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
danmakuToggle.style.color = "white";
danmakuToggle.style.border = "1px solid gray";
danmakuToggle.style.padding = "0 10px";
danmakuToggle.style.boxSizing = "border-box";
danmakuToggle.style.fontSize = "1em";
danmakuToggle.addEventListener("click", async () => {
danmu_enabled = !danmu_enabled;
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
// clear background color
danmakuToggle.style.backgroundColor = danmu_enabled
? "rgba(0, 128, 255, 0.5)"
: "rgba(255, 0, 0, 0.5)";
});
danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku);
});
}
shakaSpacer.appendChild(danmakuToggle);
// create a area that overlay half top of the video, which shows danmakus floating from right to left
const overlay = document.createElement("div");
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.position = "absolute";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
overlay.style.paddingTop = "10%";
// place overlay to the top of the video
video.parentElement.appendChild(overlay);
// Store the positions of the last few danmakus to avoid overlap
const danmakuPositions = [];
function danmu_handler(content: string) {
const danmaku = document.createElement("p");
danmaku.style.position = "absolute";
// Calculate a random position for the danmaku
let topPosition = 0;
let attempts = 0;
do {
topPosition = Math.random() * 30;
attempts++;
} while (
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
attempts < 10
);
// Record the position
danmakuPositions.push(topPosition);
if (danmakuPositions.length > 10) {
danmakuPositions.shift(); // Keep the last 10 positions
}
danmaku.style.top = `${topPosition}%`;
danmaku.style.right = "0";
danmaku.style.color = "white";
danmaku.style.fontSize = "1.2em";
danmaku.style.whiteSpace = "nowrap";
danmaku.style.transform = "translateX(100%)";
danmaku.style.transition = "transform 10s linear";
danmaku.style.pointerEvents = "none";
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = content;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
});
danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku);
});
}
shakaSpacer.appendChild(danmakuToggle);
}
// create a playback rate select to of shaka-spacer
const playbackRateSelect = document.createElement("select");
@@ -315,6 +368,55 @@
shakaSpacer.appendChild(playbackRateSelect);
let danmu_statistics: { ts: number; count: number }[] = [];
if (platform == "bilibili") {
// create a danmu statistics select into shaka-spacer
let statisticKey = "";
const statisticKeyInput = document.createElement("input");
statisticKeyInput.style.height = "30px";
statisticKeyInput.style.width = "100px";
statisticKeyInput.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
statisticKeyInput.style.color = "white";
statisticKeyInput.style.border = "1px solid gray";
statisticKeyInput.style.padding = "0 10px";
statisticKeyInput.style.boxSizing = "border-box";
statisticKeyInput.style.fontSize = "1em";
statisticKeyInput.style.right = "75px";
statisticKeyInput.placeholder = "弹幕统计过滤";
statisticKeyInput.style.position = "absolute";
function update_statistics() {
let counts = {};
danmu_records.forEach((e) => {
if (statisticKey != "" && !e.content.includes(statisticKey)) {
return;
}
const timeSlot = Math.floor(e.ts / 10000) * 10000; // 将时间戳向下取整到10秒
counts[timeSlot] = (counts[timeSlot] || 0) + 1;
});
danmu_statistics = [];
for (let ts in counts) {
danmu_statistics.push({ ts: parseInt(ts), count: counts[ts] });
}
}
update_statistics();
if (isLive()) {
setInterval(async () => {
update_statistics();
}, 10 * 1000);
}
statisticKeyInput.addEventListener("change", () => {
statisticKey = statisticKeyInput.value;
update_statistics();
});
shakaSpacer.appendChild(statisticKeyInput);
}
// shaka-spacer should be flex-direction: column
shakaSpacer.style.flexDirection = "column";
@@ -336,6 +438,15 @@
}
switch (e.key) {
case "[":
e.preventDefault();
start = parseFloat(video.currentTime.toFixed(2));
if (end < start) {
end = get_total();
}
console.log(start, end);
break;
case "【":
e.preventDefault();
start = parseFloat(video.currentTime.toFixed(2));
if (end < start) {
end = get_total();
@@ -343,6 +454,15 @@
console.log(start, end);
break;
case "]":
e.preventDefault();
end = parseFloat(video.currentTime.toFixed(2));
if (start > end) {
start = 0;
}
console.log(start, end);
break;
case "】":
e.preventDefault();
end = parseFloat(video.currentTime.toFixed(2));
if (start > end) {
start = 0;
@@ -350,6 +470,7 @@
console.log(start, end);
break;
case " ":
e.preventDefault();
if (e.repeat) {
break;
}
@@ -359,22 +480,31 @@
video.pause();
}
break;
case "m":
case "p":
e.preventDefault();
if (e.repeat) {
break;
}
video.muted = !video.muted;
// dispatch event
dispatch("markerAdd", {
offset: video.currentTime,
realtime: ts + video.currentTime,
});
break;
case "ArrowLeft":
e.preventDefault();
video.currentTime -= 3;
break;
case "ArrowRight":
e.preventDefault();
video.currentTime += 3;
break;
case "q":
e.preventDefault();
video.currentTime = start;
break;
case "e":
e.preventDefault();
if (end == 0) {
video.currentTime = get_total();
} else {
@@ -382,23 +512,101 @@
}
break;
case "c":
e.preventDefault();
start = 0;
end = 0;
break;
case "h":
e.preventDefault();
show_detail = !show_detail;
break;
}
});
const seekbarContainer = selfSeekbar.querySelector(
".shaka-seek-bar-container.self-defined"
) as HTMLElement;
const statisticGraph = document.createElement(
"canvas"
) as HTMLCanvasElement;
statisticGraph.style.pointerEvents = "none";
statisticGraph.style.position = "absolute";
statisticGraph.style.bottom = "11px";
statisticGraph.style.zIndex = "20";
const canvas = statisticGraph.getContext("2d");
seekbarContainer.appendChild(statisticGraph);
// draw statistics
function drawStatistics(points: { ts: number; count: number }[]) {
if (points == undefined) {
points = [];
}
// preprocess points
let preprocessed = [];
for (let i = 1; i < points.length; i++) {
preprocessed.push(points[i - 1]);
let gap = (points[i].ts - points[i - 1].ts) / 1000;
if (gap > 10) {
// add zero point to fill gap
let cnt = 1;
while (gap > 10) {
preprocessed.push({
ts: points[i - 1].ts + cnt * 10 * 1000,
count: 0,
});
cnt += 1;
gap -= 10;
}
}
}
if (points.length > 0) {
preprocessed.push(points[points.length - 1]);
}
const scale = window.devicePixelRatio || 1;
statisticGraph.width = seekbarContainer.clientWidth * scale;
statisticGraph.height = 30 * scale;
statisticGraph.style.width = `${seekbarContainer.clientWidth}px`;
statisticGraph.style.height = "30px";
const canvasHeight = statisticGraph.height;
const canvasWidth = statisticGraph.width;
// find value range
const minValue = 0;
const maxValue = Math.max(...preprocessed.map((v) => v.count));
const beginTime = player.getPresentationStartTimeAsDate().getTime();
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 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 y =
(1 - (preprocessed[i].count - minValue) / (maxValue - minValue)) *
canvasHeight;
canvas.lineTo(x, y);
if (i == preprocessed.length - 1) {
canvas.lineTo(x, canvasHeight);
}
}
canvas.strokeStyle = "rgba(245, 166, 39, 0.5)";
canvas.stroke();
canvas.lineTo(x, canvasHeight);
canvas.closePath();
canvas.fillStyle = "rgba(245, 166, 39, 0.5)";
canvas.fill();
}
}
function updateSeekbar() {
const total = get_total();
const first_point = start / total;
const second_point = end / total;
// set background color for self-defined seekbar between first_point and second_point using linear-gradient
const seekbarContainer = selfSeekbar.querySelector(
".shaka-seek-bar-container.self-defined",
) as HTMLElement;
seekbarContainer.style.background = `linear-gradient(to right, rgba(255, 255, 255, 0.4) ${
first_point * 100
}%, rgb(0, 255, 0) ${first_point * 100}%, rgb(0, 255, 0) ${
@@ -408,13 +616,42 @@
}%, rgba(255, 255, 255, 0.4) ${
first_point * 100
}%, rgba(255, 255, 255, 0.2) ${first_point * 100}%)`;
// render markers in shaka-ad-markers
const adMarkers = document.querySelector(
".shaka-ad-markers"
) as HTMLElement;
if (adMarkers) {
// clean previous markers
adMarkers.innerHTML = "";
for (const marker of markers) {
const markerElement = document.createElement("div");
markerElement.style.position = "absolute";
markerElement.style.width = "6px";
markerElement.style.height = "7px";
markerElement.style.backgroundColor = "#93A8AC";
markerElement.style.left = `calc(${(marker.offset / total) * 100}% - 3px)`;
markerElement.style.top = "-12px";
markerElement.style.zIndex = "30";
// little triangle on the bottom
const triangle = document.createElement("div");
triangle.style.width = "0";
triangle.style.height = "0";
triangle.style.borderLeft = "3px solid transparent";
triangle.style.borderRight = "3px solid transparent";
triangle.style.borderTop = "4px solid #93A8AC";
triangle.style.position = "absolute";
triangle.style.top = "7px";
triangle.style.left = "0";
markerElement.appendChild(triangle);
adMarkers.appendChild(markerElement);
}
drawStatistics(danmu_statistics);
}
requestAnimationFrame(updateSeekbar);
}
requestAnimationFrame(updateSeekbar);
}
meta_parse();
// receive tauri emit
document.addEventListener("shaka-ui-loaded", init);
@@ -448,10 +685,43 @@
<p><kbd></kbd>前进</p>
<p><kbd></kbd>后退</p>
<p><kbd>c</kbd>清除选区</p>
<p><kbd>m</kbd>静音</p>
<p><kbd>p</kbd>创建标记</p>
</span>
{/if}
</div>
<div id="shortcuts">
<button
id="shortcut-btn"
on:click={() => {
show_list = !show_list;
}}
>
<GridOutline />
</button>
{#if show_list}
<ul class="shortcut-list">
{#each recorders as recorder}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
class="shortcut"
on:click={() => {
go_to(
recorder.platform,
recorder.room_id,
recorder.current_live_id
);
}}
>
<SortHorizontalOutline />[{recorder.user_info.user_name}]{recorder
.room_info.room_title}
</li>
{/each}
{#if recorders.length == 0}
<p>没有其它正在直播的房间</p>
{/if}
</ul>
{/if}
</div>
<style>
video {
@@ -471,7 +741,7 @@
}
#overlay {
position: fixed;
position: absolute;
top: 8px;
left: 8px;
border-radius: 6px;
@@ -483,4 +753,45 @@
font-size: 0.8em;
pointer-events: none;
}
#shortcuts {
position: absolute;
top: 8px;
right: 8px;
flex-direction: column;
display: flex;
align-items: end;
color: white;
font-size: 0.8em;
z-index: 501;
}
#shortcut-btn {
width: 36px;
padding: 8px;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.5);
}
#shortcut-btn:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.shortcut-list {
border-radius: 4px;
padding: 8px;
background-color: rgba(0, 0, 0, 0.5);
}
.shortcut {
display: flex;
flex-direction: row;
cursor: pointer;
}
.shortcut:hover {
text-decoration: underline;
}
</style>

View File

@@ -1,333 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { message } from "@tauri-apps/plugin-dialog";
import {
Badge,
SpeedDial,
SpeedDialButton,
Table,
TableBody,
TableBodyCell,
TableBodyRow,
TableHead,
TableHeadCell,
Dropdown,
DropdownItem,
Button,
CheckboxButton,
ButtonGroup,
Modal,
Label,
Select,
Checkbox,
Input,
Helper,
Tooltip,
} from "flowbite-svelte";
import {
ChevronDownOutline,
PlusOutline,
ExclamationCircleOutline,
} from "flowbite-svelte-icons";
import type { RecorderList } from "./interface";
import Image from "./Image.svelte";
import type { RecordItem } from "./db";
export let room_count = 0;
let summary: RecorderList = {
count: 0,
recorders: [],
};
async function update_summary() {
summary = (await invoke("get_recorder_list")) as RecorderList;
room_count = summary.count;
}
update_summary();
setInterval(update_summary, 1000);
function format_time(time: number) {
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
let seconds = Math.floor(time % 60);
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
// modals
let deleteModal = false;
let deleteRoom = 0;
let quickClipModal = false;
let quickClipRoom = 0;
let quickClipSelected = 0;
let quickClipOptions = [
{ value: 10, name: "10 秒" },
{ value: 30, name: "30 秒" },
{ value: 60, name: "60 秒" },
];
let addModal = false;
let addRoom = "";
let addValid = false;
let addErrorMsg = "";
let archiveModal = false;
let archiveRoom = null;
let archives: RecordItem[] = [];
async function showArchives(room_id: number) {
archives = await invoke("get_archives", { roomId: room_id });
archiveModal = true;
console.log(archives);
}
function format_ts(ts_string: string) {
const date = new Date(ts_string);
return date.toLocaleString();
}
function format_duration(duration: number) {
const hours = Math.floor(duration / 3600)
.toString()
.padStart(2, "0");
const minutes = Math.floor((duration % 3600) / 60)
.toString()
.padStart(2, "0");
const seconds = (duration % 60).toString().padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
}
function format_size(size: number) {
if (size < 1024) {
return `${size} B`;
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KiB`;
} else if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
} else {
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}
}
function calc_bitrate(size: number, duration: number) {
return ((size * 8) / duration / 1024).toFixed(0);
}
</script>
<div class="p-8 pt-12 h-full overflow-auto">
<Table hoverable={true} divClass="relative max-h-full" shadow>
<TableHead>
<TableHeadCell>房间号</TableHeadCell>
<TableHeadCell>标题</TableHeadCell>
<TableHeadCell>用户</TableHeadCell>
<TableHeadCell>状态</TableHeadCell>
<TableHeadCell>缓存时长</TableHeadCell>
<TableHeadCell>
<span class="sr-only">Edit</span>
</TableHeadCell>
</TableHead>
<TableBody tableBodyClass="divide-y">
{#each summary.recorders as room}
<TableBodyRow>
<TableBodyCell>{room.room_id}</TableBodyCell>
<TableBodyCell>{room.room_info.room_title}</TableBodyCell>
<TableBodyCell>
<div class="pr-4">
<Image
iclass="rounded-full w-12 inline"
src={room.user_info.user_avatar_url}
/>
<span>
{room.user_info.user_name}
</span>
</div>
</TableBodyCell>
<TableBodyCell>
{#if room.live_status}
<Badge color="green">直播中</Badge>
{:else}
<Badge color="dark">未直播</Badge>
{/if}
</TableBodyCell>
<TableBodyCell>{format_time(room.total_length)}</TableBodyCell>
<TableBodyCell>
<Button size="sm" color="dark"
>操作<ChevronDownOutline
class="w-6 h-6 ms-2 text-white dark:text-white"
/></Button
>
<Dropdown>
{#if room.live_status}
<DropdownItem
on:click={async () => {
await invoke("open_live", {
roomId: room.room_id,
ts: room.current_ts,
});
}}>打开直播流</DropdownItem
>
<!-- <DropdownItem
on:click={() => {
quickClipRoom = room.room_id;
quickClipSelected = 30;
quickClipModal = true;
}}>快速切片</DropdownItem
> -->
{/if}
<DropdownItem
on:click={() => {
archiveRoom = room;
showArchives(room.room_id);
}}>查看历史记录</DropdownItem
>
<DropdownItem
class="text-red-500"
on:click={() => {
deleteRoom = room.room_id;
deleteModal = true;
}}>移除直播间</DropdownItem
>
</Dropdown>
</TableBodyCell>
</TableBodyRow>
{/each}
</TableBody>
</Table>
<div class="fixed end-4 bottom-4">
<Button
pill={true}
class="!p-2"
on:click={() => {
addModal = true;
}}><PlusOutline class="w-8 h-8" /></Button
>
</div>
<Modal bind:open={deleteModal} size="xs" autoclose>
<div class="text-center">
<ExclamationCircleOutline
class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200"
/>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
确定要移除这个直播间吗?
</h3>
<Button
color="red"
class="me-2"
on:click={async () => {
await invoke("remove_recorder", { roomId: deleteRoom });
}}>确定</Button
>
<Button color="alternative">取消</Button>
</div>
</Modal>
<Modal title="快速切片" bind:open={quickClipModal} size="xs" autoclose>
<Label>
选择切片时长
<Select
class="mt-2"
items={quickClipOptions}
bind:value={quickClipSelected}
/>
</Label>
<Checkbox>生成后启动上传流程</Checkbox>
<Checkbox>生成后打开文件所在目录</Checkbox>
<div class="text-center">
<Button color="red" class="me-2">确定</Button>
<Button color="alternative">取消</Button>
</div>
</Modal>
<Modal title="新增直播间" bind:open={addModal} size="xs" autoclose>
<Label color={addErrorMsg ? "red" : "gray"}>
房间号
<Input
bind:value={addRoom}
color={addErrorMsg ? "red" : "base"}
on:change={() => {
if (!addRoom) {
addErrorMsg = "";
addValid = false;
return;
}
// TODO preload room info
const room_id = Number(addRoom);
if (Number.isInteger(room_id) && room_id > 0) {
addErrorMsg = "";
addValid = true;
} else {
addErrorMsg = "房间号格式错误,请检查输入";
addValid = false;
}
}}
/>
{#if addErrorMsg}
<Helper class="mt-2" color="red">
<span class="font-medium">{addErrorMsg}</span>
</Helper>
{/if}
</Label>
<div class="text-center">
<Button
color="red"
class="me-2"
disabled={!addValid}
on:click={() => {
invoke("add_recorder", { roomId: Number(addRoom) }).catch(
async (e) => {
await message("请检查房间号是否有效:" + e, "添加失败");
}
);
}}>确定</Button
>
<Button color="alternative">取消</Button>
</div>
</Modal>
<Modal title="直播间记录" bind:open={archiveModal} size="lg">
<Table>
<TableHead>
<TableHeadCell>直播时间</TableHeadCell>
<TableHeadCell>标题</TableHeadCell>
<TableHeadCell>时长</TableHeadCell>
<TableHeadCell>缓存</TableHeadCell>
<TableHeadCell>操作</TableHeadCell>
</TableHead>
<TableBody tableBodyClass="divide-y">
{#each archives as archive}
<TableBodyRow>
<TableBodyCell>{format_ts(archive.created_at)}</TableBodyCell>
<TableBodyCell>{archive.title}</TableBodyCell>
<TableBodyCell>{format_duration(archive.length)}</TableBodyCell>
<TableBodyCell>
<span>{format_size(archive.size)}</span>
</TableBodyCell>
<TableBodyCell>
<ButtonGroup>
<Button
on:click={() => {
invoke("open_live", {
roomId: archiveRoom.room_id,
ts: archive.live_id,
});
}}>编辑切片</Button
>
<Button
color="red"
on:click={() => {
invoke("delete_archive", {
roomId: archiveRoom.room_id,
ts: archive.live_id,
}).then(async () => {
archives = await invoke("get_archives", {
roomId: archiveRoom.room_id,
});
});
}}>移除</Button
>
</ButtonGroup>
</TableBodyCell>
</TableBodyRow>
{/each}
</TableBody>
</Table>
</Modal>
</div>

View File

@@ -1,132 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import {
Button,
ButtonGroup,
Toggle,
Input,
Label,
Card,
} from "flowbite-svelte";
import type { Config } from "./interface";
let setting_model: Config = {
cache: "",
output: "",
primary_uid: 0,
live_start_notify: true,
live_end_notify: true,
clip_notify: true,
post_notify: true,
};
async function get_config() {
let config: Config = await invoke("get_config");
setting_model = config;
console.log(config);
}
async function browse_folder() {
const selected = await open({ directory: true });
return Array.isArray(selected) ? selected[0] : selected;
}
async function update_notify() {
await invoke("update_notify", {
liveStartNotify: setting_model.live_start_notify,
liveEndNotify: setting_model.live_end_notify,
clipNotify: setting_model.clip_notify,
postNotify: setting_model.post_notify,
});
}
get_config();
</script>
<div class="p-8 pt-12">
<Card>
<h5
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
>
通知设置
</h5>
<Toggle
class="mb-2"
bind:checked={setting_model.live_start_notify}
on:change={update_notify}>开播通知</Toggle
>
<Toggle
class="mb-2"
bind:checked={setting_model.live_end_notify}
on:change={update_notify}>下播通知</Toggle
>
<Toggle
class="mb-2"
bind:checked={setting_model.clip_notify}
on:change={update_notify}>切片完成通知</Toggle
>
<Toggle
class="mb-2"
bind:checked={setting_model.post_notify}
on:change={update_notify}>投稿完成通知</Toggle
>
</Card>
<Card size="xl" class="mt-4">
<h5
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
>
目录设置
</h5>
<Label>缓存目录</Label>
<ButtonGroup>
<Input value={setting_model.cache} readonly />
<Button
color="primary"
on:click={async () => {
const new_folder = await browse_folder();
if (new_folder) {
setting_model.cache = new_folder;
await invoke("set_cache_path", {
cachePath: setting_model.cache,
});
}
}}>Browse</Button
>
<Button
color="alternative"
on:click={async () => {
await invoke("show_in_folder", {
path: setting_model.cache,
});
}}>Open</Button
>
</ButtonGroup>
<Label class="mt-4">输出目录</Label>
<ButtonGroup>
<Input value={setting_model.output} readonly />
<Button
color="primary"
on:click={async () => {
const new_folder = await browse_folder();
if (new_folder) {
setting_model.output = new_folder;
await invoke("set_output_path", {
outputPath: setting_model.output,
});
}
}}>Browse</Button
>
<Button
color="alternative"
on:click={async () => {
await invoke("show_in_folder", {
path: setting_model.output,
});
}}>Open</Button
>
</ButtonGroup>
</Card>
</div>

View File

@@ -1,131 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { fetch } from "@tauri-apps/plugin-http";
import { Card, List, Li, Tooltip } from "flowbite-svelte";
import { GithubSolid, GlobeSolid } from "flowbite-svelte-icons";
import Image from "./Image.svelte";
import type { RecorderList, DiskInfo } from "./interface";
import type { RecordItem } from "./db";
const INTERVAL = 5000;
let summary: RecorderList = {
count: 0,
recorders: [],
};
let disk_info: DiskInfo = {
disk: "",
total: 0,
free: 0,
};
let total = 0;
let online = 0;
let disk_usage = 0;
async function update_summary() {
summary = (await invoke("get_recorder_list")) as RecorderList;
total = summary.count;
online = summary.recorders.filter((r) => r.live_status).length;
// each recorder get archive size
console.log(summary.recorders);
let new_disk_usage = 0;
for (const recorder of summary.recorders) {
new_disk_usage += await get_disk_usage(recorder.room_id);
}
disk_usage = new_disk_usage;
// get disk info
disk_info = await invoke("get_disk_info");
}
update_summary();
setInterval(update_summary, INTERVAL);
async function get_disk_usage(room_id: number) {
let ds = 0;
const archives = (await invoke("get_archives", {
roomId: room_id,
})) as RecordItem[];
for (const archive of archives) {
ds += archive.size;
}
return ds;
}
function format_size(size: number) {
if (size < 1024) {
return `${size} B`;
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KiB`;
} else if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
} else {
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}
}
interface Sponser {
name: string;
avatar: string;
}
let sponsers: Sponser[] = [];
async function get_sponsers() {
const response = await fetch(
"https://afdian.com/api/creator/get-sponsors?user_id=bbb3f596df9c11ea922752540025c377&type=new&page=1",
);
const data = await response.json();
console.log(data);
if (data.ec == 200) {
sponsers = data.data.list.slice(0, 10);
}
}
get_sponsers();
</script>
<div class="grid grid-cols-2 gap-4 p-8 pt-12">
<Card class="!max-w-none">
<h5
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
>
支持该项目的开发
</h5>
<List tag="ul" class="space-y-1 text-gray-500">
<Li
>反馈 BUG 或提供建议:<a
href="https://github.com/Xinrea/bili-shadowreplay"
target="_blank"><GithubSolid class="inline" />GitHub</a
></Li
>
<Li
>赞助:<a href="https://afdian.com/a/Xinrea" target="_blank"
><GlobeSolid class="inline" />爱发电</a
></Li
>
</List>
<div class="mt-4 flex flex-row items-center">
<span>感谢</span>
{#each sponsers as sp}
<Image iclass="rounded-full w-8" src={sp.avatar} />
<Tooltip>{sp.name}</Tooltip>
{/each}
<span>等的赞助</span>
</div>
</Card>
<Card class="!max-w-none">
<h5
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
>
直播间总览
</h5>
<p class="font-normal text-gray-700 dark:text-gray-400 leading-tight">
目前共有 {total} 个直播间,其中 {online} 个正在直播,{total -
online} 个未直播;共占用磁盘空间 {format_size(disk_usage)}
</p>
<p class="font-normal text-gray-700 dark:text-gray-400 leading-tight">
直播缓存所在磁盘:{disk_info.disk},总容量 {format_size(
disk_info.total,
)},剩余容量 {format_size(disk_info.free)}
</p>
</Card>
</div>

View File

@@ -1,81 +0,0 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
const appWindow = getCurrentWindow();
export let dark = false;
</script>
<div data-tauri-drag-region class="titlebar z-[1000]" class:dark>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="titlebar-button"
id="titlebar-minimize"
on:click={() => appWindow.minimize()}
>
<img
src="https://api.iconify.design/mdi:window-minimize.svg"
alt="minimize"
/>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="titlebar-button"
id="titlebar-maximize"
on:click={async () => {
let m = await appWindow.isMaximized();
if (m) {
appWindow.unmaximize();
} else {
appWindow.maximize();
}
}}
>
<img
src="https://api.iconify.design/mdi:window-maximize.svg"
alt="maximize"
/>
</div>
<div class="titlebar-button" id="titlebar-close">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<img
src="https://api.iconify.design/mdi:close.svg"
alt="close"
on:click={() => appWindow.close()}
/>
</div>
</div>
<style>
.titlebar {
height: 35px;
user-select: none;
display: flex;
justify-content: flex-end;
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0)
);
}
.titlebar-button {
display: inline-flex;
justify-content: center;
align-items: center;
width: 35px;
height: 35px;
user-select: none;
-webkit-user-select: none;
}
.titlebar-button:hover {
@apply bg-gray-50 bg-opacity-50;
}
.dark .titlebar-button:hover {
@apply bg-gray-300 bg-opacity-50;
}
</style>

86
src/lib/TypeSelect.svelte Normal file
View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { Dropdown, DropdownItem, Select } from "flowbite-svelte";
import { ChevronDownOutline } from "flowbite-svelte-icons";
import type { Children, VideoType } from "./interface";
export let value = 0;
let parentSelected: VideoType;
let areaSelected: Children;
let parentOpen = false;
let areaOpen = false;
let items: VideoType[] = [];
async function get_video_typelist() {
items = (await invoke("get_video_typelist")) as VideoType[];
// find parentSelected by value
let valid = false;
for (let i = 0; i < items.length; i++) {
for (let j = 0; j < items[i].children.length; j++) {
if (items[i].children[j].id === value) {
parentSelected = items[i];
areaSelected = items[i].children[j];
valid = true;
break;
}
}
}
if (!valid) {
parentSelected = items[0];
areaSelected = items[0].children[0];
value = areaSelected.id;
}
}
get_video_typelist();
</script>
<div class="flex">
<button
class="z-10 w-2/5 inline-flex justify-between items-center py-2.5 px-4 text-sm font-medium text-center rounded-s-lg focus:border-primary-500 focus:ring-primary-500 bg-gray-700 text-white placeholder-gray-400 border border-gray-600"
type="button"
>
{parentSelected ? parentSelected.name : ""}
<ChevronDownOutline class="w-6 h-6 ms-2" />
</button>
<Dropdown
bind:open={parentOpen}
containerClass="divide-y z-50 h-48 overflow-y-auto w-24"
>
{#each items as item}
<DropdownItem
on:click={() => {
parentOpen = false;
areaOpen = false;
parentSelected = item;
areaSelected = parentSelected.children[0];
value = areaSelected.id;
}}
class="flex items-center">{item.name}</DropdownItem
>
{/each}
</Dropdown>
<button
class="z-10 w-3/5 inline-flex justify-between items-center py-2.5 px-4 text-sm font-medium text-center rounded-e-lg focus:border-primary-500 focus:ring-primary-500 bg-gray-700 text-white placeholder-gray-400 border border-gray-600"
type="button"
>
{areaSelected ? areaSelected.name : ""}
<ChevronDownOutline class="w-6 h-6 ms-2" />
</button>
<Dropdown
bind:open={areaOpen}
containerClass="divide-y z-50 h-48 overflow-y-auto min-w-32 bg-gray-700 text-gray-200 rounded-lg border-gray-100 border-gray-600 divide-gray-100 divide-gray-600 shadow-md"
>
{#each parentSelected.children as child}
<DropdownItem
on:click={() => {
areaOpen = false;
parentOpen = false;
areaSelected = child;
value = child.id;
}}
class="flex items-center">{child.name}</DropdownItem
>
{/each}
</Dropdown>
</div>
<style>
</style>

View File

@@ -1,6 +1,6 @@
import Database from "@tauri-apps/plugin-sql";
export const db = await Database.load("sqlite:data.db");
export const db = await Database.load("sqlite:data_v2.db");
// sql: r#"
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, length INTEGER, size INTEGER, created_at TEXT);
@@ -10,11 +10,13 @@ export const db = await Database.load("sqlite:data.db");
// "#,
export interface RecorderItem {
platform: string;
room_id: number;
created_at: string;
}
export interface AccountItem {
platform: string;
uid: number;
name: string;
avatar: string;
@@ -33,79 +35,17 @@ export interface MessageItem {
// from RecordRow
export interface RecordItem {
platform: string;
title: string;
live_id: number;
live_id: string;
room_id: number;
length: number;
size: number;
created_at: string;
cover: string;
}
export interface AccountInfo {
primary_uid: number;
accounts: AccountItem[];
}
// CREATE TABLE recorders (room_id INTEGER PRIMARY KEY, created_at TEXT);
export class Recorders {
static async add(room_id: number): Promise<boolean> {
const result = await db.execute(
"INSERT into recorders (room_id, created_at) VALUES ($1, $2)",
[room_id, new Date().toISOString()],
);
return result.rowsAffected == 1;
}
static async remove(room_id: number): Promise<boolean> {
const result = await db.execute("DELETE FROM recirders WHERE room_id=$1", [
room_id,
]);
return result.rowsAffected == 1;
}
static async query(): Promise<RecorderItem[]> {
return await db.select("SELECT * FROM recorders");
}
}
function parseCookies(cookies_str: string) {
const cookies = cookies_str.split("; ");
const cookieObject = {};
cookies.forEach((cookie) => {
const [name, value] = cookie.split("=");
cookieObject[decodeURIComponent(name)] = decodeURIComponent(value);
});
return cookieObject;
}
// CREATE TABLE accounts (uid INTEGER PRIMARY KEY, name TEXT, avatar TEXT, csrf TEXT, cookies TEXT, created_at TEXT);
export class Accounts {
static async login(): Promise<boolean> {
const result = (await db.select("SELECT * FROM accounts")) as AccountItem[];
return result.length > 0;
}
static async add(cookies: string): Promise<boolean> {
const obj = parseCookies(cookies);
const uid = parseInt(obj["DedeUserID"]);
const csrf = obj["bili_jct"];
const result = await db.execute(
"INSERT OR REPLACE INTO accounts (uid, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
[uid, name, avatar, csrf, cookies, new Date().toISOString],
);
return result.rowsAffected == 1;
}
static async remove(uid: number): Promise<boolean> {
const result = await db.execute("DELETE FROM accounts WHERE uid = $1", [
uid,
]);
return result.rowsAffected == 1;
}
static async query(): Promise<AccountItem[]> {
return await db.select("SELECT * FROM accounts");
}
}
}

View File

@@ -1,6 +1,6 @@
export interface RoomInfo {
live_status: number;
room_cover_url: string;
room_cover: string;
room_id: number;
room_keyframe_url: string;
room_title: string;
@@ -11,15 +11,16 @@ export interface UserInfo {
user_id: string;
user_name: string;
user_sign: string;
user_avatar_url: string;
user_avatar: string;
}
export interface RecorderInfo {
platform: string;
room_id: number;
room_info: RoomInfo;
user_info: UserInfo;
total_length: number;
current_ts: number;
current_live_id: string;
live_status: boolean;
}
@@ -89,6 +90,7 @@ export interface Config {
live_end_notify: boolean;
clip_notify: boolean;
post_notify: boolean;
auto_cleanup: boolean;
}
export interface DiskInfo {
@@ -96,3 +98,44 @@ export interface DiskInfo {
total: number;
free: number;
}
export interface VideoType {
id: number;
parent: number;
parent_name: string;
name: string;
description: string;
desc: string;
intro_original: string;
intro_copy: string;
notice: string;
copy_right: number;
show: boolean;
rank: number;
children: Children[];
max_video_count: number;
request_id: string;
}
export interface Children {
id: number;
parent: number;
parent_name: string;
name: string;
description: string;
desc: string;
intro_original: string;
intro_copy: string;
notice: string;
copy_right: number;
show: boolean;
rank: number;
max_video_count: number;
request_id: string;
}
export interface Marker {
offset: number;
realtime: number;
content: string;
}

201
src/page/About.svelte Normal file
View File

@@ -0,0 +1,201 @@
<script type="ts">
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { open } from "@tauri-apps/plugin-shell";
import { BookOpen, MessageCircle, Video, Heart } from "lucide-svelte";
const appWindow = getCurrentWebviewWindow();
let version = "";
let showDonateModal = false;
getVersion().then((v) => {
version = v;
appWindow.setTitle(`BiliBili ShadowReplay - v${version}`);
console.log(version);
});
let latest_version = "";
let releases = [];
// get releases from github api
fetch("https://api.github.com/repos/Xinrea/bili-shadowreplay/releases")
.then((response) => response.json())
.then((data) => {
latest_version = data[0].tag_name;
releases = data.slice(0, 3).map((release) => ({
version: release.tag_name,
date: new Date(release.published_at).toLocaleDateString(),
description: release.body,
}));
});
function formatReleaseNotes(notes) {
if (!notes) return [];
return notes
.split("\n")
.filter(
(line) => line.trim().startsWith("*") || line.trim().startsWith("-")
)
.map((line) => {
line = line.trim().replace(/^[*-]\s*/, "");
// Remove commit hash at the end (- hash or hash)
line = line
.replace(/\s*-\s*[a-f0-9]{40}$/, "")
.replace(/\s+[a-f0-9]{40}$/, "");
return line;
})
.filter((line) => line.length > 0);
}
function toggleDonateModal() {
showDonateModal = !showDonateModal;
}
function handleModalClickOutside(event) {
const modal = document.querySelector(".mac-modal");
if (modal && !modal.contains(event.target)) {
showDonateModal = false;
}
}
</script>
<div class="flex-1 p-6 overflow-auto">
<div class="max-w-2xl mx-auto space-y-8">
<!-- App Info -->
<div class="text-center space-y-4">
<div
class="w-24 h-24 mx-auto bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl shadow-lg flex items-center justify-center"
>
<Video class="w-12 h-12 icon-white" />
</div>
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
BiliBili ShadowReplay
</h1>
<p class="text-gray-500 dark:text-gray-400">Version {version}</p>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-3 gap-4">
<button
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
on:click={() => {
// tauri open url
open("https://github.com/Xinrea/bili-shadowreplay/wiki");
}}
>
<div class="flex flex-col items-center space-y-2">
<div
class="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center"
>
<BookOpen class="w-5 h-5 icon-primary" />
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white"
>说明</span
>
</div>
</button>
<button
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
on:click={() => {
// tauri open url
open("https://github.com/Xinrea/bili-shadowreplay/issues");
}}
>
<div class="flex flex-col items-center space-y-2">
<div
class="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center"
>
<MessageCircle class="w-5 h-5 icon-primary" />
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white"
>意见反馈</span
>
</div>
</button>
<button
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
on:click={toggleDonateModal}
>
<div class="flex flex-col items-center space-y-2">
<div
class="w-10 h-10 rounded-full bg-pink-500/10 flex items-center justify-center"
>
<Heart class="w-5 h-5 text-pink-500" />
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white"
>打赏支持</span
>
</div>
</button>
</div>
<!-- What's New -->
<div class="space-y-4">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
What's New
</h2>
<div
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700"
>
{#each releases as release}
<div
class="p-4 {release !== releases[releases.length - 1]
? 'border-b border-gray-200 dark:border-gray-700'
: ''}"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
Version {release.version}
</h3>
<span class="text-xs text-gray-500 dark:text-gray-400"
>Released on {release.date}</span
>
</div>
<ul class="mt-2 space-y-1 text-sm text-gray-600 dark:text-gray-300">
{#each formatReleaseNotes(release.description) as note}
<li class="flex items-start space-x-2">
<span class="text-blue-500"></span>
<span>{note}</span>
</li>
{/each}
</ul>
</div>
{/each}
</div>
</div>
</div>
</div>
{#if showDonateModal}
<div
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center"
style="position: absolute; min-height: 100%; width: 100%; top: 0; left: 0;"
>
<div
class="bg-white dark:bg-[#3c3c3e] rounded-lg p-6 max-w-md w-full mx-4 mac-modal"
>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
打赏支持
</h3>
<button
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
on:click={toggleDonateModal}
>
</button>
</div>
<div class="flex justify-center">
<img
src="/imgs/donate.png"
class="max-w-full h-auto rounded-lg"
alt="打赏二维码"
/>
</div>
<p class="mt-4 text-center text-sm text-gray-600 dark:text-gray-300">
感谢您的支持!
</p>
</div>
</div>
{/if}
<svelte:window on:mousedown={handleModalClickOutside} />

388
src/page/Account.svelte Normal file
View File

@@ -0,0 +1,388 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { scale, fade } from "svelte/transition";
import { Textarea } from "flowbite-svelte";
import Image from "../lib/Image.svelte";
import QRCode from "qrcode";
import type { AccountItem, AccountInfo } from "../lib/db";
import { Ellipsis, Plus } from "lucide-svelte";
let account_info: AccountInfo = {
primary_uid: 0,
accounts: [],
};
async function update_accounts() {
account_info = await invoke("get_accounts");
}
update_accounts();
let addModal = false;
let activeTab = "qr"; // 'qr' or 'manual'
let selectedPlatform = "bilibili"; // 'bilibili' or 'douyin'
let oauth_key = "";
let check_interval = null;
let cookie_str = "";
let manualModal = false;
let activeDropdown = null;
function toggleDropdown(uid) {
if (activeDropdown === uid) {
activeDropdown = null;
} else {
activeDropdown = uid;
}
}
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (
activeDropdown !== null &&
!event.target.closest(".dropdown-container")
) {
activeDropdown = null;
}
}
function handleModalClickOutside(event) {
const modal = document.querySelector(".mac-modal");
if (
modal &&
!modal.contains(event.target) &&
!event.target.closest("button")
) {
addModal = false;
}
}
async function handle_qr() {
if (check_interval) {
clearInterval(check_interval);
}
let qr_info: { url: string; oauthKey: string } = await invoke("get_qr");
oauth_key = qr_info.oauthKey;
const canvas = document.getElementById("qr");
QRCode.toCanvas(canvas, qr_info.url, function (error) {
if (error) {
console.log(error);
return;
}
canvas.style.display = "block";
check_interval = setInterval(check_qr, 2000);
});
}
async function check_qr() {
let qr_status: { code: number; cookies: string } = await invoke(
"get_qr_status",
{ qrcodeKey: oauth_key }
);
if (qr_status.code == 0) {
clearInterval(check_interval);
await invoke("add_account", {
cookies: qr_status.cookies,
platform: selectedPlatform,
});
await update_accounts();
addModal = false;
}
}
async function add_cookie() {
if (cookie_str == "") {
return;
}
try {
console.log("add_cookie", cookie_str, selectedPlatform);
await invoke("add_account", {
cookies: cookie_str,
platform: selectedPlatform,
});
await update_accounts();
cookie_str = "";
addModal = false;
} catch (e) {
alert("添加账号失败:" + e);
}
}
</script>
<svelte:window
on:click={handleClickOutside}
on:mousedown={handleModalClickOutside}
/>
<div class="flex-1 p-6 overflow-auto">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
账号
</h1>
<div
class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"
>
<span>{account_info.accounts.length}</span>
</div>
</div>
<button
on:click={() => {
addModal = true;
if (activeTab === "qr") {
requestAnimationFrame(handle_qr);
}
}}
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center space-x-2"
>
<Plus class="w-5 h-5 icon-white" />
<span>添加账号</span>
</button>
</div>
<!-- Account List -->
<div class="space-y-4">
<!-- Online Account -->
{#each account_info.accounts.sort( (a, b) => (b.uid === account_info.primary_uid ? 1 : a.uid === account_info.primary_uid ? -1 : 0) ) as account (account.uid)}
<div
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="relative">
<Image
iclass="w-12 h-12 rounded-full object-cover"
src={account.avatar}
/>
</div>
<div>
<div class="flex items-center space-x-2">
<h3 class="font-medium text-gray-900 dark:text-white">
{account.platform === "bilibili"
? account.name
: "抖音账号" + account.uid}
</h3>
{#if account.uid == account_info.primary_uid}
<span
class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 text-xs"
>主账号</span
>
{/if}
</div>
{#if account.platform === "bilibili"}
<p class="text-sm text-gray-600 dark:text-gray-400">
UID: {account.uid}
</p>
{/if}
{#if account.platform === "douyin"}
<p class="text-sm text-gray-600 dark:text-gray-400">
仅用于获取直播流
</p>
{/if}
</div>
</div>
<div class="flex items-center space-x-3">
<div class="relative dropdown-container">
<button
class="p-2 rounded-lg hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
on:click|stopPropagation={() => toggleDropdown(account.uid)}
>
<Ellipsis class="w-5 h-5 dark:icon-white" />
</button>
{#if activeDropdown === account.uid}
<div
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 backdrop-blur-xl bg-opacity-90 dark:bg-opacity-90"
style="transform-origin: top right;"
in:scale={{ duration: 100, start: 0.95 }}
out:scale={{ duration: 100, start: 0.95 }}
>
{#if account.uid !== account_info.primary_uid && account.platform === "bilibili"}
<button
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-white hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c] rounded-t-lg"
on:click={async () => {
await invoke("set_primary", { uid: account.uid });
await update_accounts();
activeDropdown = null;
}}
>
设为主账号
</button>
{/if}
<button
class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c] {account.uid !==
account_info.primary_uid
? ''
: 'rounded-t-lg'} rounded-b-lg"
on:click={async () => {
await invoke("remove_account", {
platform: account.platform,
uid: account.uid,
});
await update_accounts();
activeDropdown = null;
}}
>
注销账号
</button>
</div>
{/if}
</div>
</div>
</div>
</div>
{/each}
<!-- Add Account Card -->
<button
class="w-full p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
on:click={() => {
addModal = true;
if (activeTab === "qr") {
requestAnimationFrame(handle_qr);
}
}}
>
<div class="flex flex-col items-center justify-center space-y-2">
<div
class="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center"
>
<Plus class="w-6 h-6 icon-primary" />
</div>
<div class="text-center">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
添加新账号
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
添加一个新账号,用于获取直播流和投稿
</p>
</div>
</div>
</button>
</div>
</div>
</div>
{#if addModal}
<div
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
transition:fade={{ duration: 200 }}
>
<div
class="mac-modal w-[400px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden"
transition:scale={{ duration: 150, start: 0.95 }}
>
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700/50">
<h2 class="text-base font-medium text-gray-900 dark:text-white">
添加账号
</h2>
</div>
<div class="p-6 space-y-6">
<!-- Platform Selection -->
<div class="space-y-2">
<label
for="platform"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
平台
</label>
<div class="flex p-0.5 bg-[#f5f5f7] dark:bg-[#1c1c1e] rounded-lg">
<button
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors {selectedPlatform ===
'bilibili'
? 'bg-white dark:bg-[#3c3c3e] shadow-sm text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}"
on:click={() => {
selectedPlatform = "bilibili";
activeTab = "qr";
requestAnimationFrame(handle_qr);
}}
>
哔哩哔哩
</button>
<button
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors {selectedPlatform ===
'douyin'
? 'bg-white dark:bg-[#3c3c3e] shadow-sm text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}"
on:click={() => {
selectedPlatform = "douyin";
activeTab = "manual";
}}
>
抖音
</button>
</div>
</div>
<!-- Login Methods (Only show for Bilibili) -->
{#if selectedPlatform === "bilibili"}
<div class="flex rounded-lg bg-[#f5f5f7] dark:bg-[#1c1c1e] p-1">
<button
class="flex-1 px-4 py-1.5 text-sm rounded-md transition-colors {activeTab ===
'qr'
? 'bg-white dark:bg-[#3c3c3e] shadow-sm font-medium'
: 'text-gray-600 dark:text-gray-400'}"
on:click={() => {
activeTab = "qr";
requestAnimationFrame(handle_qr);
}}
>
扫码登录
</button>
<button
class="flex-1 px-4 py-1.5 text-sm rounded-md transition-colors {activeTab ===
'manual'
? 'bg-white dark:bg-[#3c3c3e] shadow-sm font-medium'
: 'text-gray-600 dark:text-gray-400'}"
on:click={() => {
activeTab = "manual";
}}
>
手动输入
</button>
</div>
{/if}
<!-- Tab Content -->
<div class="space-y-4">
{#if selectedPlatform === "bilibili" && activeTab === "qr"}
<div class="flex flex-col items-center space-y-4">
<div class="bg-white p-4 rounded-lg">
<canvas id="qr" />
</div>
<p class="text-sm text-center text-gray-600 dark:text-gray-400">
请使用 BiliBili App 扫描二维码登录
</p>
</div>
{:else}
<div class="space-y-4">
<Textarea
bind:value={cookie_str}
rows="4"
class="w-full px-3 py-2 bg-[#f5f5f7] dark:bg-[#1c1c1e] border-0 rounded-lg resize-none focus:ring-2 focus:ring-blue-500"
placeholder={selectedPlatform === "bilibili"
? "请粘贴 BiliBili 账号的 Cookie"
: "请粘贴抖音账号的 Cookie"}
/>
<div class="flex justify-end">
<button
class="px-4 py-2 bg-[#0A84FF] hover:bg-[#0A84FF]/90 text-white text-sm font-medium rounded-lg transition-colors"
on:click={() => {
add_cookie();
}}
>
添加账号
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}

716
src/page/Room.svelte Normal file
View File

@@ -0,0 +1,716 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { message } from "@tauri-apps/plugin-dialog";
import { fade, scale } from "svelte/transition";
import { Dropdown, DropdownItem } from "flowbite-svelte";
import { open } from "@tauri-apps/plugin-shell";
import type { RecorderList } from "../lib/interface";
import Image from "../lib/Image.svelte";
import type { RecordItem } from "../lib/db";
import {
Ellipsis,
Play,
Plus,
Scissors,
Search,
Trash2,
X,
History,
} from "lucide-svelte";
import BilibiliIcon from "../lib/BilibiliIcon.svelte";
import DouyinIcon from "../lib/DouyinIcon.svelte";
export let room_count = 0;
let room_active = 0;
let room_inactive = 0;
let summary: RecorderList = {
count: 0,
recorders: [],
};
let searchQuery = "";
$: filteredRecorders = summary.recorders.filter((room) => {
const query = searchQuery.toLowerCase();
return (
room.room_info.room_title.toLowerCase().includes(query) ||
room.user_info.user_name.toLowerCase().includes(query) ||
room.room_id.toString().includes(query)
);
});
async function update_summary() {
summary = (await invoke("get_recorder_list")) as RecorderList;
room_count = summary.count;
room_active = summary.recorders.filter((room) => room.live_status).length;
room_inactive = summary.recorders.filter(
(room) => !room.live_status
).length;
}
update_summary();
setInterval(update_summary, 1000);
function format_time(time: number) {
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
let seconds = Math.floor(time % 60);
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
// modals
let deleteModal = false;
let deleteRoom = null;
let addModal = false;
let addRoom = "";
let addValid = false;
let addErrorMsg = "";
let selectedPlatform = "bilibili";
let archiveModal = false;
let archiveRoom = null;
let archives: RecordItem[] = [];
async function showArchives(room_id: number) {
archives = await invoke("get_archives", { roomId: room_id });
archiveModal = true;
console.log(archives);
}
function format_ts(ts_string: string) {
const date = new Date(ts_string);
return date.toLocaleString();
}
function format_duration(duration: number) {
const hours = Math.floor(duration / 3600)
.toString()
.padStart(2, "0");
const minutes = Math.floor((duration % 3600) / 60)
.toString()
.padStart(2, "0");
const seconds = (duration % 60).toString().padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
}
function format_size(size: number) {
if (size < 1024) {
return `${size} B`;
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KiB`;
} else if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
} else {
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}
}
function calc_bitrate(size: number, duration: number) {
return ((size * 8) / duration / 1024).toFixed(0);
}
function handleModalClickOutside(event) {
const modal = document.querySelector(".mac-modal");
if (
modal &&
!modal.contains(event.target) &&
!event.target.closest("button")
) {
addModal = false;
archiveModal = false;
}
}
</script>
<div class="flex-1 p-6 overflow-auto">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
直播间
</h1>
<div
class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"
>
<span class="flex items-center space-x-1">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
<span>{room_active} 直播中</span>
</span>
<span></span>
<span>{room_inactive} 未直播</span>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="relative">
<Search
class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
type="text"
bind:value={searchQuery}
placeholder="搜索直播间..."
class="pl-10 pr-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white"
/>
</div>
<button
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center space-x-2"
on:click={() => {
addModal = true;
}}
>
<Plus class="w-5 h-5 icon-white" />
<span>添加新直播间</span>
</button>
</div>
</div>
<!-- Room Grid -->
<div class="grid grid-cols-3 gap-4">
<!-- Active Room Card -->
{#each filteredRecorders as room (room.room_id)}
{#if room.live_status}
<div
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="relative">
<Image
src={room.room_info.room_cover}
iclass="w-full h-40 object-cover rounded-lg"
/>
<div
class="absolute top-2 left-2 px-2 py-1 rounded-full bg-green-500 text-white text-xs flex items-center space-x-1"
>
<span>直播中</span>
</div>
<button
class="absolute top-2 right-2 p-1.5 rounded-lg bg-gray-900/50 hover:bg-gray-900/70 transition-colors"
>
<Ellipsis class="w-5 h-5 icon-white" />
</button>
<Dropdown class="whitespace-nowrap">
<DropdownItem
on:click={() => {
open("https://live.bilibili.com/" + room.room_id);
}}>打开网页直播间</DropdownItem
>
<DropdownItem
class="text-red-500"
on:click={() => {
deleteRoom = room;
deleteModal = true;
}}>移除直播间</DropdownItem
>
</Dropdown>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center space-x-2">
{#if room.platform === "bilibili"}
<BilibiliIcon class="w-4 h-4" />
{:else if room.platform === "douyin"}
<DouyinIcon class="w-4 h-4" />
{/if}
<h3 class="font-medium text-gray-900 dark:text-white">
{room.room_info.room_title}
</h3>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div
class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400"
>
<button
class="flex items-center space-x-2 p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
on:click={() => {
if (room.platform === "bilibili") {
open(
"https://space.bilibili.com/" + room.user_info.user_id
);
} else if (room.platform === "douyin") {
console.log(room.user_info);
open(
"https://www.douyin.com/user/" +
room.user_info.user_id
);
}
}}
>
<Image
src={room.user_info.user_avatar}
iclass="w-8 h-8 rounded-full"
/>
<span>{room.user_info.user_name}</span>
</button>
</div>
<div class="flex items-center space-x-1">
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
on:click={() => {
invoke("open_live", {
platform: room.platform,
roomId: room.room_id,
liveId: room.current_live_id,
});
}}
>
<Play class="w-5 h-5 dark:icon-white" />
</button>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
on:click={() => {
archiveRoom = room;
showArchives(room.room_id);
}}
>
<History class="w-5 h-5 dark:icon-white" />
</button>
</div>
</div>
</div>
</div>
{/if}
{/each}
<!-- Inactive Room Card -->
{#each filteredRecorders as room (room.room_id)}
{#if !room.live_status}
<div
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="relative">
<Image
src={room.room_info.room_cover}
iclass="w-full h-40 object-cover rounded-lg brightness-75"
/>
<div
class="absolute top-2 left-2 px-2 py-1 rounded-full bg-gray-700 text-white text-xs flex items-center space-x-1"
>
<span>未直播</span>
</div>
<button
class="absolute top-2 right-2 p-1.5 rounded-lg bg-gray-900/50 hover:bg-gray-900/70 transition-colors"
>
<Ellipsis class="w-5 h-5" color="white" />
</button>
<Dropdown class="whitespace-nowrap">
{#if room.live_status}
<DropdownItem
on:click={async () => {
await invoke("open_live", {
platform: room.platform,
roomId: room.room_id,
liveId: room.current_live_id,
});
}}>打开直播流</DropdownItem
>
{/if}
<DropdownItem
on:click={() => {
open("https://live.bilibili.com/" + room.room_id);
}}>打开网页直播间</DropdownItem
>
<DropdownItem
class="text-red-500"
on:click={() => {
deleteRoom = room;
deleteModal = true;
}}>移除直播间</DropdownItem
>
</Dropdown>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center space-x-2">
{#if room.platform === "bilibili"}
<BilibiliIcon class="w-4 h-4" />
{:else if room.platform === "douyin"}
<DouyinIcon class="w-4 h-4" />
{/if}
<h3 class="font-medium text-gray-900 dark:text-white">
{room.room_info.room_title}
</h3>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div
class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400"
>
<button
class="flex items-center space-x-2 p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
on:click={() => {
if (room.platform === "bilibili") {
open(
"https://space.bilibili.com/" + room.user_info.user_id
);
} else if (room.platform === "douyin") {
console.log(room.user_info);
open(
"https://www.douyin.com/user/" +
room.user_info.user_id
);
}
}}
>
<Image
src={room.user_info.user_avatar}
iclass="w-8 h-8 rounded-full"
/>
<span>{room.user_info.user_name}</span>
</button>
</div>
<div class="flex items-center space-x-1">
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
on:click={() => {
archiveRoom = room;
showArchives(room.room_id);
}}
>
<History class="w-5 h-5 dark:icon-white" />
</button>
</div>
</div>
</div>
</div>
{/if}
{/each}
<!-- Add Room Card -->
<button
class="p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-400 transition-colors flex flex-col items-center justify-center space-y-2"
on:click={() => {
addModal = true;
}}
>
<div
class="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center"
>
<Plus class="w-6 h-6 icon-primary" />
</div>
<span class="text-sm font-medium text-blue-600 dark:text-blue-400"
>添加新直播间</span
>
<span class="text-xs text-gray-500 dark:text-gray-400"
>配置一个新直播间以及其相关设置</span
>
</button>
</div>
</div>
</div>
{#if deleteModal}
<div
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
transition:fade={{ duration: 200 }}
>
<div
class="mac-modal w-[320px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden"
transition:scale={{ duration: 150, start: 0.95 }}
>
<div class="p-6 space-y-4">
<div class="text-center space-y-2">
<h3 class="text-base font-medium text-gray-900 dark:text-white">
移除直播间
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
此操作将移除所有相关的录制记录
</p>
</div>
<div class="flex justify-center space-x-3">
<button
class="w-24 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] rounded-lg transition-colors"
on:click={() => {
deleteModal = false;
}}
>
取消
</button>
<button
class="w-24 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors"
on:click={async () => {
await invoke("remove_recorder", {
roomId: deleteRoom.room_id,
platform: deleteRoom.platform,
});
deleteModal = false;
}}
>
移除
</button>
</div>
</div>
</div>
</div>
{/if}
{#if addModal}
<div
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
transition:fade={{ duration: 200 }}
>
<div
class="mac-modal w-[400px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden"
transition:scale={{ duration: 150, start: 0.95 }}
>
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700/50">
<h2 class="text-base font-medium text-gray-900 dark:text-white">
添加直播间
</h2>
</div>
<div class="p-6 space-y-6">
<div class="space-y-4">
<div class="space-y-2">
<label
for="platform"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
平台
</label>
<div class="flex p-0.5 bg-[#f5f5f7] dark:bg-[#1c1c1e] rounded-lg">
<button
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors {selectedPlatform ===
'bilibili'
? 'bg-white dark:bg-[#323234] shadow-sm text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}"
on:click={() => (selectedPlatform = "bilibili")}
>
哔哩哔哩
</button>
<button
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors {selectedPlatform ===
'douyin'
? 'bg-white dark:bg-[#323234] shadow-sm text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}"
on:click={() => (selectedPlatform = "douyin")}
>
抖音
</button>
</div>
</div>
<div class="space-y-2">
<label
for="room_id"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{selectedPlatform === "bilibili" ? "房间号" : "直播间ID"}
</label>
<input
id="room_id"
type="text"
bind:value={addRoom}
class="w-full px-3 py-2 bg-[#f5f5f7] dark:bg-[#1c1c1e] border-0 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder={selectedPlatform === "bilibili"
? "请输入直播间房间号"
: "请输入抖音直播间ID"}
on:change={() => {
if (!addRoom) {
addErrorMsg = "";
addValid = false;
return;
}
const room_id = Number(addRoom);
if (Number.isInteger(room_id) && room_id > 0) {
addErrorMsg = "";
addValid = true;
} else {
addErrorMsg = "ID格式错误请检查输入";
addValid = false;
}
}}
/>
{#if addErrorMsg}
<p class="text-sm text-red-600 dark:text-red-500">
{addErrorMsg}
</p>
{/if}
</div>
<div class="flex justify-end space-x-3">
<button
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] rounded-lg transition-colors"
on:click={() => {
addModal = false;
}}
>
取消
</button>
<button
class="px-4 py-2 bg-[#0A84FF] hover:bg-[#0A84FF]/90 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!addValid}
on:click={() => {
invoke("add_recorder", {
roomId: Number(addRoom),
platform: selectedPlatform,
})
.then(() => {
addModal = false;
addRoom = "";
})
.catch(async (e) => {
await message(e);
});
}}
>
添加
</button>
</div>
</div>
</div>
</div>
</div>
{/if}
{#if archiveModal}
<div
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
transition:fade={{ duration: 200 }}
>
<div
class="mac-modal w-[900px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden flex flex-col max-h-[80vh]"
transition:scale={{ duration: 150, start: 0.95 }}
>
<!-- Header -->
<div
class="flex justify-between items-center px-6 py-4 border-b border-gray-200 dark:border-gray-700/50"
>
<div class="flex items-center space-x-3">
<h2 class="text-base font-medium text-gray-900 dark:text-white">
直播间记录
</h2>
<span class="text-sm text-gray-500 dark:text-gray-400">
{archiveRoom?.user_info.user_name} · {archiveRoom?.room_id}
</span>
</div>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
on:click={() => (archiveModal = false)}
>
<X class="w-5 h-5 dark:icon-white" />
</button>
</div>
<div class="flex-1 overflow-auto">
<div class="p-6">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700/50">
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
>直播时间</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
>标题</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
>时长</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
>大小</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
>码率</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
>操作</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700/50">
{#each archives as archive}
<tr
class="group hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] transition-colors"
>
<td class="px-4 py-3">
<div class="flex flex-col">
<span class="text-sm text-gray-900 dark:text-white"
>{format_ts(archive.created_at).split(" ")[0]}</span
>
<span class="text-xs text-gray-500 dark:text-gray-400"
>{format_ts(archive.created_at).split(" ")[1]}</span
>
</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-3">
{#if archive.cover}
<Image
src={archive.cover}
iclass="w-12 h-8 rounded object-cover"
/>
{/if}
<span class="text-sm text-gray-900 dark:text-white"
>{archive.title}</span
>
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white"
>{format_duration(archive.length)}</td
>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white"
>{format_size(archive.size)}</td
>
<td
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>{calc_bitrate(archive.size, archive.length)} Kbps</td
>
<td class="px-4 py-3">
<div
class="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
class="p-1.5 rounded-lg hover:bg-blue-500/10 transition-colors"
title="编辑切片"
on:click={() => {
invoke("open_live", {
platform: archiveRoom.platform,
roomId: archiveRoom.room_id,
liveId: archive.live_id,
});
}}
>
<Scissors class="w-4 h-4 icon-primary" />
</button>
<button
class="p-1.5 rounded-lg hover:bg-red-500/10 transition-colors"
title="删除记录"
on:click={() => {
invoke("delete_archive", {
platform: archiveRoom.platform,
roomId: archiveRoom.room_id,
liveId: archive.live_id,
})
.then(async () => {
archives = await invoke("get_archives", {
roomId: archiveRoom.room_id,
});
})
.catch((e) => {
alert(e);
});
}}
>
<Trash2 class="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{/if}
<svelte:window on:mousedown={handleModalClickOutside} />

284
src/page/Setting.svelte Normal file
View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import type { Config } from "../lib/interface";
import { Bell, HardDrive, AlertTriangle } from "lucide-svelte";
let setting_model: Config = {
cache: "",
output: "",
primary_uid: 0,
live_start_notify: true,
live_end_notify: true,
clip_notify: true,
post_notify: true,
auto_cleanup: true,
};
let showModal = false;
async function get_config() {
let config: Config = await invoke("get_config");
setting_model = config;
console.log(config);
}
async function browse_folder() {
const selected = await open({ directory: true });
return Array.isArray(selected) ? selected[0] : selected;
}
async function update_notify() {
await invoke("update_notify", {
liveStartNotify: setting_model.live_start_notify,
liveEndNotify: setting_model.live_end_notify,
clipNotify: setting_model.clip_notify,
postNotify: setting_model.post_notify,
});
}
async function handleCacheChange() {
showModal = true;
}
async function handleOutputChange() {
const new_folder = await browse_folder();
if (new_folder) {
setting_model.output = new_folder;
await invoke("set_output_path", {
outputPath: setting_model.output,
});
}
}
async function confirmChange() {
showModal = false;
const new_folder = await browse_folder();
if (new_folder) {
setting_model.cache = new_folder;
await invoke("set_cache_path", {
cachePath: setting_model.cache,
});
}
}
get_config();
</script>
<div class="flex-1 overflow-auto">
<div class="h-screen">
<div class="p-6 space-y-6">
<!-- Header -->
<div
class="flex items-center justify-between dark:bg-[#1c1c1e] py-2 -mt-2 z-10"
>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
Settings
</h1>
</div>
<!-- Settings Sections -->
<div class="space-y-6 pb-6">
<!-- Storage Settings -->
<div class="space-y-4">
<h2
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
>
<HardDrive class="w-5 h-5 dark:icon-white" />
<span>存储设置</span>
</h2>
<div
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"
>
<!-- Cache Location -->
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
缓存路径
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{setting_model.cache}
</p>
</div>
<button
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
on:click={handleCacheChange}
>
变更
</button>
</div>
</div>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
切片保存路径
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{setting_model.output}
</p>
</div>
<button
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
on:click={handleOutputChange}
>
变更
</button>
</div>
</div>
</div>
</div>
<!-- Notification Settings -->
<div class="space-y-4">
<h2
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
>
<Bell class="w-5 h-5 dark:icon-white" />
<span>通知设置</span>
</h2>
<div
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"
>
<!-- Stream Start -->
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
直播开始通知
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
当直播间开始直播时,会收到通知
</p>
</div>
<label class="relative inline-block w-11 h-6">
<input
type="checkbox"
class="peer opacity-0 w-0 h-0"
bind:checked={setting_model.live_start_notify}
on:change={update_notify}
/>
<span
class="switch-slider absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-300 dark:bg-gray-600 rounded-full transition-all duration-300 before:absolute before:h-4 before:w-4 before:left-1 before:bottom-1 before:bg-white before:rounded-full before:transition-all before:duration-300 peer-checked:bg-blue-500 peer-checked:before:translate-x-5"
></span>
</label>
</div>
</div>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
下播通知
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
当直播间结束直播时,会收到通知
</p>
</div>
<label class="relative inline-block w-11 h-6">
<input
type="checkbox"
class="peer opacity-0 w-0 h-0"
bind:checked={setting_model.live_end_notify}
on:change={update_notify}
/>
<span
class="switch-slider absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-300 dark:bg-gray-600 rounded-full transition-all duration-300 before:absolute before:h-4 before:w-4 before:left-1 before:bottom-1 before:bg-white before:rounded-full before:transition-all before:duration-300 peer-checked:bg-blue-500 peer-checked:before:translate-x-5"
></span>
</label>
</div>
</div>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
切片完成通知
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
当切片完成时,会收到通知
</p>
</div>
<label class="relative inline-block w-11 h-6">
<input
type="checkbox"
class="peer opacity-0 w-0 h-0"
bind:checked={setting_model.clip_notify}
on:change={update_notify}
/>
<span
class="switch-slider absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-300 dark:bg-gray-600 rounded-full transition-all duration-300 before:absolute before:h-4 before:w-4 before:left-1 before:bottom-1 before:bg-white before:rounded-full before:transition-all before:duration-300 peer-checked:bg-blue-500 peer-checked:before:translate-x-5"
></span>
</label>
</div>
</div>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
投稿完成通知
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
当投稿完成时,会收到通知
</p>
</div>
<label class="relative inline-block w-11 h-6">
<input
type="checkbox"
class="peer opacity-0 w-0 h-0"
bind:checked={setting_model.post_notify}
on:change={update_notify}
/>
<span
class="switch-slider absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-300 dark:bg-gray-600 rounded-full transition-all duration-300 before:absolute before:h-4 before:w-4 before:left-1 before:bottom-1 before:bg-white before:rounded-full before:transition-all before:duration-300 peer-checked:bg-blue-500 peer-checked:before:translate-x-5"
></span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
{#if showModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white dark:bg-[#2c2c2e] rounded-xl p-6 max-w-md w-full mx-4">
<div class="flex items-start space-x-3 mb-4">
<AlertTriangle class="w-6 h-6 text-yellow-500 flex-shrink-0" />
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
确认变更
</h3>
<p class="text-gray-600 dark:text-gray-400 mt-2">
根据文件大小,可能需要耗时较长时间,迁移期间直播间会暂时移除,迁移完成后直播间会自动恢复。
</p>
<p class="text-gray-600 dark:text-gray-400 mt-2 font-bold">
迁移期间请不要关闭程序,且不要在迁移期间再次更改目录!
</p>
<p class="text-gray-600 dark:text-gray-400 mt-2">
确认要进行变更吗?
</p>
</div>
</div>
<div class="flex justify-end space-x-4">
<button
class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
on:click={() => (showModal = false)}
>
取消
</button>
<button
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
on:click={confirmChange}
>
确认
</button>
</div>
</div>
</div>
{/if}

458
src/page/Summary.svelte Normal file
View File

@@ -0,0 +1,458 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import type { RecorderList, DiskInfo } from "../lib/interface";
import type { RecordItem } from "../lib/db";
const INTERVAL = 1000;
import { scale } from "svelte/transition";
import { CalendarCheck, Clock, Database, HardDrive, Play, RefreshCw, Trash2, Users, Video } from "lucide-svelte";
let summary: RecorderList = {
count: 0,
recorders: [],
};
let disk_info: DiskInfo = {
disk: "",
total: 0,
free: 0,
};
let total = 0;
let online = 0;
let disk_usage = 0;
let account_count = 0;
let total_length = 0;
let today_record_count = 0;
let recent_records: RecordItem[] = [];
let activeDropdown = null;
let loading = false;
let offset = 0;
let hasMore = true;
let hasNewRecords = false;
const RECORDS_PER_PAGE = 5;
async function update_summary() {
summary = (await invoke("get_recorder_list")) as RecorderList;
total = summary.count;
online = summary.recorders.filter((r) => r.live_status).length;
let new_disk_usage = 0;
for (const recorder of summary.recorders) {
new_disk_usage += await get_disk_usage(recorder.room_id);
}
disk_usage = new_disk_usage;
// get disk info
disk_info = await invoke("get_disk_info");
account_count = await get_account_count();
// get total length
total_length = await get_total_length();
// get today record count
today_record_count = await get_today_record_count();
// check for new records
if (recent_records.length > 0) {
const latestRecords = (await invoke("get_recent_record", {
offset: 0,
limit: 1,
})) as RecordItem[];
if (latestRecords.length > 0 && (!recent_records[0] || latestRecords[0].live_id !== recent_records[0].live_id)) {
hasNewRecords = true;
}
} else {
// Initial load
await loadMoreRecords();
}
}
async function loadMoreRecords() {
if (loading || (!hasMore && !hasNewRecords)) return;
loading = true;
const newRecords = (await invoke("get_recent_record", {
offset: hasNewRecords ? 0 : offset,
limit: RECORDS_PER_PAGE,
})) as RecordItem[];
if (hasNewRecords) {
recent_records = newRecords;
offset = newRecords.length;
hasNewRecords = false;
hasMore = true;
} else {
if (newRecords.length < RECORDS_PER_PAGE) {
hasMore = false;
}
recent_records = [...recent_records, ...newRecords];
offset += newRecords.length;
}
console.log(recent_records);
loading = false;
}
function handleScroll(event) {
const target = event.target;
// If we're at the top and there are new records, load them
if (target.scrollTop === 0 && hasNewRecords) {
loadMoreRecords();
return;
}
// Otherwise check if we need to load more old records
const bottom = target.scrollHeight - target.scrollTop - target.clientHeight < 50;
if (bottom && !hasNewRecords) {
loadMoreRecords();
}
}
update_summary();
setInterval(update_summary, INTERVAL);
async function get_disk_usage(room_id: number) {
let ds = 0;
const archives = (await invoke("get_archives", {
roomId: room_id,
})) as RecordItem[];
for (const archive of archives) {
ds += archive.size;
}
return ds;
}
async function get_total_length(): Promise<number> {
return await invoke("get_total_length");
}
async function get_today_record_count(): Promise<number> {
return await invoke("get_today_record_count");
}
async function get_account_count(): Promise<number> {
return await invoke("get_account_count");
}
function format_size(size: number) {
if (size < 1024) {
return `${size} B`;
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KiB`;
} else if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
} else {
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}
}
function format_time(time: number) {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = time % 60;
// two digits
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
// format date to YYYY-MM-DD HH:MM:SS
function format_date(date: string) {
return new Date(date).toLocaleString();
}
function toggleDropdown(id) {
if (activeDropdown === id) {
activeDropdown = null;
} else {
activeDropdown = id;
}
}
function handleClickOutside(event) {
if (activeDropdown !== null && !event.target.closest(".dropdown-container")) {
activeDropdown = null;
}
}
async function deleteRecord(record: RecordItem) {
try {
await invoke("delete_archive", {
platform: record.platform,
roomId: record.room_id,
liveId: record.live_id,
});
// Remove the record from the list
recent_records = recent_records.filter(r => r.live_id !== record.live_id);
// Update stats
disk_usage -= record.size;
total_length -= record.length;
if (new Date(record.created_at).toDateString() === new Date().toDateString()) {
today_record_count--;
}
} catch (error) {
alert(error);
}
}
async function refreshRecords() {
// Reset pagination
offset = 0;
hasMore = true;
recent_records = [];
// Load records from beginning
await loadMoreRecords();
}
</script>
<svelte:window on:click={handleClickOutside} />
<div class="flex-1 p-6 overflow-y-auto" on:scroll={handleScroll}>
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">总览</h1>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-3 gap-6">
<!-- Cache Size -->
<div
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="p-3 rounded-lg bg-blue-500">
<HardDrive class="w-6 h-6 icon-white" />
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">缓存占用</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{format_size(disk_usage)}
</p>
</div>
</div>
</div>
<div
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="p-3 rounded-lg bg-orange-500">
<Database class="w-6 h-6 icon-white" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-baseline justify-between">
<p class="text-sm text-gray-600 dark:text-gray-400">磁盘使用</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{format_size(disk_info.free)}剩余
</p>
</div>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{format_size(disk_info.total - disk_info.free)}
</p>
<div
class="w-full h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden mt-1.5"
>
<div
class="h-full bg-orange-500 rounded-full"
style="width: {((disk_info.total - disk_info.free) /
disk_info.total) *
100}%"
></div>
</div>
</div>
</div>
</div>
<!-- Active Rooms -->
<div
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="p-3 rounded-lg bg-green-500">
<Video class="w-6 h-6 icon-white" />
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">直播间</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{online} / {total}
</p>
</div>
</div>
</div>
<!-- Connected Accounts -->
<div
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="p-3 rounded-lg bg-purple-500">
<Users class="w-6 h-6 icon-white" />
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">账号</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{account_count}
</p>
</div>
</div>
</div>
<!-- Total Recording Time -->
<div
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="p-3 rounded-lg bg-indigo-500">
<Clock class="w-6 h-6 icon-white" />
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">总缓存时长</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{format_time(total_length)}
</p>
</div>
</div>
</div>
<!-- Today's Recordings -->
<div
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="p-3 rounded-lg bg-pink-500">
<CalendarCheck class="w-6 h-6 icon-white" />
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">
今日缓存直播数
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{today_record_count}
</p>
</div>
</div>
</div>
</div>
<!-- Recent Recordings -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
最近的直播记录
</h2>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors text-gray-500 dark:text-gray-400"
on:click={refreshRecords}
>
<RefreshCw class="w-5 h-5 dark:icon-white" />
</button>
</div>
{#if hasNewRecords}
<button
class="px-3 py-1 text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 rounded-full hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors"
on:click={loadMoreRecords}
>
记录有更新 • 点击刷新
</button>
{/if}
</div>
<div class="space-y-3">
<!-- Recording Items -->
{#each recent_records as record}
<div
class="p-4 rounded-lg bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 flex items-center justify-between hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
>
<div class="flex items-center space-x-4">
{#if record.cover !== ""}
<img
src={record.cover}
class="w-32 h-18 rounded-lg object-cover"
alt="Gaming stream thumbnail"
/>
{/if}
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
{record.title}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{format_date(record.created_at)}{format_size(record.size)}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
on:click={() => {
invoke("open_live", {
platform: record.platform,
roomId: record.room_id,
liveId: record.live_id,
});
}}
>
<Play class="w-5 h-5 dark:icon-white" />
</button>
<div class="relative dropdown-container">
<button
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-red-600 dark:text-red-400"
on:click|stopPropagation={() => toggleDropdown(record.live_id)}
>
<Trash2 class="w-5 h-5 icon-danger" />
</button>
{#if activeDropdown === record.live_id}
<div
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 backdrop-blur-xl bg-opacity-90 dark:bg-opacity-90 z-50"
style="transform-origin: top right;"
in:scale={{ duration: 100, start: 0.95 }}
out:scale={{ duration: 100, start: 0.95 }}
>
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">确认删除</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">此操作无法撤销</p>
</div>
<div class="p-2 flex space-x-2">
<button
class="flex-1 px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-md transition-colors"
on:click={() => {
activeDropdown = null;
}}
>
取消
</button>
<button
class="flex-1 px-3 py-1.5 text-sm text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
on:click={() => {
deleteRecord(record);
activeDropdown = null;
}}
>
删除
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
{#if loading}
<div class="flex justify-center py-4">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
{/if}
{#if !hasMore && recent_records.length > 0}
<div class="text-center py-4 text-gray-500 dark:text-gray-400">
没有更多记录了
</div>
{/if}
</div>
</div>
</div>
</div>

View File

@@ -7,5 +7,17 @@ body {
height: 100%;
width: 100%;
user-select: none;
cursor: default;
}
.icon-white {
color: white;
}
.icon-primary {
color: #007bff;
}
.icon-danger {
color: #dc3545;
}

View File

@@ -10,6 +10,9 @@ const mobile =
// https://vitejs.dev/config/
// @ts-ignore
export default defineConfig(async () => ({
optimizeDeps: {
exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
},
plugins: [
svelte({
preprocess: [

286
yarn.lock
View File

@@ -31,7 +31,7 @@
"@esbuild/darwin-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz"
integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==
"@esbuild/darwin-x64@0.18.20":
@@ -121,19 +121,19 @@
"@esbuild/win32-x64@0.18.20":
version "0.18.20"
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d"
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
"@floating-ui/core@^1.6.0":
version "1.6.7"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.7.tgz#7602367795a390ff0662efd1c7ae8ca74e75fb12"
resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.7.tgz"
integrity sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==
dependencies:
"@floating-ui/utils" "^0.2.7"
"@floating-ui/dom@^1.6.10":
version "1.6.10"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.10.tgz#b74c32f34a50336c86dcf1f1c845cf3a39e26d6f"
resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.10.tgz"
integrity sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==
dependencies:
"@floating-ui/core" "^1.6.0"
@@ -141,7 +141,7 @@
"@floating-ui/utils@^0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.7.tgz"
integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
"@isaacs/cliui@^8.0.2":
@@ -224,12 +224,12 @@
"@popperjs/core@^2.9.3":
version "2.11.8"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@rollup/plugin-node-resolve@^15.2.3":
version "15.2.3"
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz#e5e0b059bd85ca57489492f295ce88c2d4b0daf9"
resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz"
integrity sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==
dependencies:
"@rollup/pluginutils" "^5.0.1"
@@ -241,7 +241,7 @@
"@rollup/pluginutils@^5.0.1":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz#7e53eddc8c7f483a4ad0b94afb1f7f5fd3c771e0"
resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz"
integrity sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==
dependencies:
"@types/estree" "^1.0.0"
@@ -268,135 +268,130 @@
svelte-hmr "^0.15.3"
vitefu "^0.2.4"
"@tauri-apps/api@2.0.0-rc.0":
version "2.0.0-rc.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-rc.0.tgz#902b4e9803ecdcc0a3913d7c09df26d651f3dfd1"
integrity sha512-v454Qs3REHc3Za59U+/eSmBsdmF+3NE5+76+lFDaitVqN4ZglDHENDaMARYKGJVZuxiSkzyqG0SeG7lLQjVkPA==
"@tauri-apps/api@^2.0.0":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.2.tgz#266767f4a4641014e86a000e7e02e1a344ced45a"
resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.2.tgz"
integrity sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==
"@tauri-apps/api@^2.0.0-rc.4":
version "2.0.0-rc.4"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-rc.4.tgz#2b4c3493d86382981787c52006c6c9e5bf16bc08"
integrity sha512-UNiIhhKG08j4ooss2oEEVexffmWkgkYlC2M3GcX3VPtNsqFgVNL8Mcw/4Y7rO9M9S+ffAMnLOF5ypzyuyb8tyg==
"@tauri-apps/api@^2.1.1":
version "2.1.1"
resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.1.1.tgz"
integrity sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==
"@tauri-apps/cli-darwin-arm64@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.0.2.tgz#5a078381ce0e9e83a710fa5ae812c8709ca993cc"
integrity sha512-B+/a8Q6wAqmB4A4HVeK0oQP5TdQGKW60ZLOI9O2ktH2HPr9ETr3XkwXPuJ2uAOuGEgtRZHBgFOIgG000vMnKlg==
"@tauri-apps/cli-darwin-arm64@2.1.0":
version "2.1.0"
resolved "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.1.0.tgz"
integrity sha512-ESc6J6CE8hl1yKH2vJ+ALF+thq4Be+DM1mvmTyUCQObvezNCNhzfS6abIUd3ou4x5RGH51ouiANeT3wekU6dCw==
"@tauri-apps/cli-darwin-x64@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.0.2.tgz#87db71cc8dfe2196b33cf5ab26b99a4fa3fa01ac"
integrity sha512-kaurhn6XT4gAVCPAQSSHl/CHFxTS0ljc47N7iGTSlYJ03sCWPRZeNuVa/bn6rolz9MA2JfnRnFqB1pUL6jzp9Q==
"@tauri-apps/cli-darwin-x64@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.1.0.tgz#08c5f446b65bc351a8e74c0c8019324ae6864351"
integrity sha512-TasHS442DFs8cSH2eUQzuDBXUST4ECjCd0yyP+zZzvAruiB0Bg+c8A+I/EnqCvBQ2G2yvWLYG8q/LI7c87A5UA==
"@tauri-apps/cli-linux-arm-gnueabihf@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.0.2.tgz#0ca2dc0b563028181bb0f005d3049c01a7dd904f"
integrity sha512-bVrofjlacMxmGMcqK18iBW05tsZXOd19/MnqruFFcHSVjvkGGIXHMtUbMXnZNXBPkHDsnfytNtkY9SZGfCFaBA==
"@tauri-apps/cli-linux-arm-gnueabihf@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.1.0.tgz#681d0967d0335b93ed8ab4b8bb5d820c72cc8abf"
integrity sha512-aP7ZBGNL4ny07Cbb6kKpUOSrmhcIK2KhjviTzYlh+pPhAptxnC78xQGD3zKQkTi2WliJLPmBYbOHWWQa57lQ9w==
"@tauri-apps/cli-linux-arm64-gnu@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.0.2.tgz#10c0baa2255dc476707bb06619e6a9bd8874c639"
integrity sha512-7XCBn0TTBVQGnV42dXcbHPLg/9W8kJoVzuliIozvNGyRWxfXqDbQYzpI48HUQG3LgHMabcw8+pVZAfGhevLrCA==
"@tauri-apps/cli-linux-arm64-gnu@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.1.0.tgz#922ddc50849ae9f2976f30fdb4b79708badb767b"
integrity sha512-ZTdgD5gLeMCzndMT2f358EkoYkZ5T+Qy6zPzU+l5vv5M7dHVN9ZmblNAYYXmoOuw7y+BY4X/rZvHV9pcGrcanQ==
"@tauri-apps/cli-linux-arm64-musl@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.2.tgz#6636e383dae70eabf9f6dd22470ad2edb9776d87"
integrity sha512-1xi2SreGVlpAL68MCsDUY63rdItUdPZreXIAcOVqvUehcJRYOa1XGSBhrV0YXRgZeh0AtKC19z6PRzcv4rosZA==
"@tauri-apps/cli-linux-arm64-musl@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.1.0.tgz#063020d217faf90b226d93b5054546ae0db14c16"
integrity sha512-NzwqjUCilhnhJzusz3d/0i0F1GFrwCQbkwR6yAHUxItESbsGYkZRJk0yMEWkg3PzFnyK4cWTlQJMEU52TjhEzA==
"@tauri-apps/cli-linux-x64-gnu@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.0.2.tgz#925e7714ad72eff67b42ed25a781b9f13262c296"
integrity sha512-WVjwYzPWFqZVg1fx6KSU5w47Q0VbMyaCp34qs5EcS8EIU0/RnofdzqUoOYqvgGVgNgoz7Pj5dXK2SkS8BHXMmA==
"@tauri-apps/cli-linux-x64-gnu@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.1.0.tgz#7d7991a2b956232f96ec614f15189d408421dc03"
integrity sha512-TyiIpMEtZxNOQmuFyfJwaaYbg3movSthpBJLIdPlKxSAB2BW0VWLY3/ZfIxm/G2YGHyREkjJvimzYE0i37PnMA==
"@tauri-apps/cli-linux-x64-musl@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.2.tgz#7e706660196c5db7ea821ceec7e315706f73cb5e"
integrity sha512-h5miE2mctgaQNn/BbG9o1pnJcrx+VGBi2A6JFqGu934lFgSV5+s28M8Gc8AF2JgFH4hQV4IuMkeSw8Chu5Dodg==
"@tauri-apps/cli-linux-x64-musl@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.1.0.tgz#af6293ce56296619d656342f11d83c35c8465979"
integrity sha512-/dQd0TlaxBdJACrR72DhynWftzHDaX32eBtS5WBrNJ+nnNb+znM3gON6nJ9tSE9jgDa6n1v2BkI/oIDtypfUXw==
"@tauri-apps/cli-win32-arm64-msvc@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.0.2.tgz#aaee06d53f5d42afe8bf994e42cf5679c9e1ebd3"
integrity sha512-2b8oO0+dYonahG5PfA/zoq0zlafLclfmXgqoWDZ++UiPtQHJNpNeEQ8GWbSFKGHQ494Jo6jHvazOojGRE1kqAg==
"@tauri-apps/cli-win32-arm64-msvc@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.1.0.tgz#adb2b17d9939cdbcb136c5e24bf90d15485265dc"
integrity sha512-NdQJO7SmdYqOcE+JPU7bwg7+odfZMWO6g8xF9SXYCMdUzvM2Gv/AQfikNXz5yS7ralRhNFuW32i5dcHlxh4pDg==
"@tauri-apps/cli-win32-ia32-msvc@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.0.2.tgz#ec6d8eef16d024eeae2ce4e3422b8a51f419e661"
integrity sha512-axgICLunFi0To3EibdCBgbST5RocsSmtM4c04+CbcX8WQQosJ9ziWlCSrrOTRr+gJERAMSvEyVUS98f6bWMw9A==
"@tauri-apps/cli-win32-ia32-msvc@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.1.0.tgz#5356a56a30bfc50a0edde3e91c16ccd3fa837d71"
integrity sha512-f5h8gKT/cB8s1ticFRUpNmHqkmaLutT62oFDB7N//2YTXnxst7EpMIn1w+QimxTvTk2gcx6EcW6bEk/y2hZGzg==
"@tauri-apps/cli-win32-x64-msvc@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.0.2.tgz#bb5d011a0d44297f148c654c05b93a71cebacaa1"
integrity sha512-JR17cM6+DyExZRgpXr2/DdqvcFYi/EKvQt8dI5R1/uQoesWd8jeNnrU7c1FG1Zmw9+pTzDztsNqEKsrNq2sNIg==
"@tauri-apps/cli-win32-x64-msvc@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.1.0.tgz#065f76de7a3aabe6d490b7f15cb09e7f8e911614"
integrity sha512-P/+LrdSSb5Xbho1LRP4haBjFHdyPdjWvGgeopL96OVtrFpYnfC+RctB45z2V2XxqFk3HweDDxk266btjttfjGw==
"@tauri-apps/cli@^2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-2.0.2.tgz#908629846f6edb1622a480f69a9469b3b9b7110e"
integrity sha512-R4ontHZvXORArERAHIidp5zRfZEshZczTiK+poslBv7AGKpQZoMw+E49zns7mOmP64i2Cq9Ci0pJvi4Rm8Okzw==
"@tauri-apps/cli@^2.1.0":
version "2.1.0"
resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.1.0.tgz"
integrity sha512-K2VhcKqBhAeS5pNOVdnR/xQRU6jwpgmkSL2ejHXcl0m+kaTggT0WRDQnFtPq6NljA7aE03cvwsbCAoFG7vtkJw==
optionalDependencies:
"@tauri-apps/cli-darwin-arm64" "2.0.2"
"@tauri-apps/cli-darwin-x64" "2.0.2"
"@tauri-apps/cli-linux-arm-gnueabihf" "2.0.2"
"@tauri-apps/cli-linux-arm64-gnu" "2.0.2"
"@tauri-apps/cli-linux-arm64-musl" "2.0.2"
"@tauri-apps/cli-linux-x64-gnu" "2.0.2"
"@tauri-apps/cli-linux-x64-musl" "2.0.2"
"@tauri-apps/cli-win32-arm64-msvc" "2.0.2"
"@tauri-apps/cli-win32-ia32-msvc" "2.0.2"
"@tauri-apps/cli-win32-x64-msvc" "2.0.2"
"@tauri-apps/cli-darwin-arm64" "2.1.0"
"@tauri-apps/cli-darwin-x64" "2.1.0"
"@tauri-apps/cli-linux-arm-gnueabihf" "2.1.0"
"@tauri-apps/cli-linux-arm64-gnu" "2.1.0"
"@tauri-apps/cli-linux-arm64-musl" "2.1.0"
"@tauri-apps/cli-linux-x64-gnu" "2.1.0"
"@tauri-apps/cli-linux-x64-musl" "2.1.0"
"@tauri-apps/cli-win32-arm64-msvc" "2.1.0"
"@tauri-apps/cli-win32-ia32-msvc" "2.1.0"
"@tauri-apps/cli-win32-x64-msvc" "2.1.0"
"@tauri-apps/plugin-dialog@^2.0.0-rc.1":
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0-rc.1.tgz#a3b0fd51a547e796ff93c58172fad48ad61a9970"
integrity sha512-H28gh6BfZtjflHQ+HrmWwunDriBI3AQLAKnMs50GA6zeNUULqbQr7VXbAAKeJL/0CmWcecID4PKXVoSlaWRhEg==
"@tauri-apps/plugin-dialog@~2":
version "2.0.1"
resolved "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.1.tgz"
integrity sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-fs@^2.0.0-rc.2":
version "2.0.0-rc.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-fs/-/plugin-fs-2.0.0-rc.2.tgz#85362170983b12ad6a4808e51daff5ba4a8916fa"
integrity sha512-TFjCfso3tN4b5s2EBjqP8N2gYrPh93Ds3VNKj8pCXv4wbvnItyfG0aHO0haUsedBOHQryDwv9vDAdPX6/T0a+g==
"@tauri-apps/plugin-fs@~2":
version "2.0.2"
resolved "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.0.2.tgz"
integrity sha512-4YZaX2j7ta81M5/DL8aN10kTnpUkEpkPo1FTYPT8Dd0ImHe3azM8i8MrtjrDGoyBYLPO3zFv7df/mSCYF8oA0Q==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-http@^2.0.0-rc.2":
version "2.0.0-rc.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-http/-/plugin-http-2.0.0-rc.2.tgz#385a3c0808db86b59ab0b183c85e4fd2de989cbd"
integrity sha512-s/AbbMaQPgqnIOoObvWNAjJOV17gyf9G+U6gmvjLoFbt7D6jsujOUW6fn+Oe/+rzNSEeo1ZSVrUoMen5DgM+OA==
"@tauri-apps/plugin-http@~2":
version "2.0.1"
resolved "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.0.1.tgz"
integrity sha512-j6IA3pVBybSCwPpsihpX4z8bs6PluuGtr06ahL/xy4D8HunNBTmRmadJrFOQi0gOAbaig4MkQ15nzNLBLy8R1A==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-notification@~2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-notification/-/plugin-notification-2.0.0.tgz#7f5e75a87e4c11d28aede278063c670befd900e3"
resolved "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.0.0.tgz"
integrity sha512-6qEDYJS7mgXZWLXA0EFL+DVCJh8sJlzSoyw6B50pxhLPVFjc5Vr5DVzl5W3mUHaYhod5wsC984eQnlCCGqxYDA==
dependencies:
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-os@^2.0.0-rc":
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.0.0-rc.1.tgz#325e23e007df6d3247654eca2741e700519ab1cd"
integrity sha512-PV8zlSTmYfiN2xzILUmlDSEycS7UYbH2yXk/ZqF+qQU6/s+OVQvmSth4EhllFjcpvPbtqELvpzfjw+2qEouchA==
"@tauri-apps/plugin-os@~2":
version "2.0.0"
resolved "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.0.0.tgz"
integrity sha512-M7hG/nNyQYTJxVG/UhTKhp9mpXriwWzrs9mqDreB8mIgqA3ek5nHLdwRZJWhkKjZrnDT4v9CpA9BhYeplTlAiA==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-shell@^2.0.0-rc.1":
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0-rc.1.tgz#9facf3bbcedfa2de676cb4cfc703687377aa12a3"
integrity sha512-JtNROc0rqEwN/g93ig5pK4cl1vUo2yn+osCpY9de64cy/d9hRzof7AuYOgvt/Xcd5VPQmlgo2AGvUh5sQRSR1A==
"@tauri-apps/plugin-shell@~2":
version "2.0.1"
resolved "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.0.1.tgz"
integrity sha512-akU1b77sw3qHiynrK0s930y8zKmcdrSD60htjH+mFZqv5WaakZA/XxHR3/sF1nNv9Mgmt/Shls37HwnOr00aSw==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-sql@^2.0.0-rc.1":
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-sql/-/plugin-sql-2.0.0-rc.1.tgz#aa21b4744cab72d082297844500c4b53837df964"
integrity sha512-F8Wq+L8SdVt76WCU161WCeM5CLkh3gzKblXhXyFFJ8m+BY1P83zVyCxriems3mYULvPPtFwlg8NEgLJycS1HYQ==
"@tauri-apps/plugin-sql@~2":
version "2.0.1"
resolved "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.0.1.tgz"
integrity sha512-SxvRO/qwq/dHHGJ+79Bx4tB/wlfUE44sP1+wpuGOp11fgmfmOaf3nlZAl0P0KX+U3h0rwR/f7PMRQ6Eg408DYQ==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/api" "^2.0.0"
"@tsconfig/node10@^1.0.7":
version "1.0.11"
@@ -425,17 +420,10 @@
"@types/estree@^1.0.0":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz"
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
"@types/node@*":
version "22.5.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.4.tgz#83f7d1f65bc2ed223bdbf57c7884f1d5a4fa84e8"
integrity sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==
dependencies:
undici-types "~6.19.2"
"@types/node@^18.7.10":
"@types/node@*", "@types/node@^18.7.10":
version "18.19.47"
resolved "https://registry.npmjs.org/@types/node/-/node-18.19.47.tgz"
integrity sha512-1f7dB3BL/bpd9tnDJrrHb66Y+cVrhxSOTGorRNdHwYTUlTay3HuTDPKo9a/4vX9pMQkhYBcAbL4jQdNlhCFP9A==
@@ -449,19 +437,19 @@
"@types/qrcode@^1.5.5":
version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
resolved "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz"
integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==
dependencies:
"@types/node" "*"
"@types/resolve@1.20.2":
version "1.20.2"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975"
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz"
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
"@yr/monotone-cubic-spline@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
resolved "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz"
integrity sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==
acorn-walk@^8.1.1:
@@ -513,7 +501,7 @@ anymatch@~3.1.2:
apexcharts@^3.53.0:
version "3.53.0"
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.53.0.tgz#9ea2b4d837d9faf2c0bff79d228db48e75b2220a"
resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-3.53.0.tgz"
integrity sha512-QESZHZY3w9LPQ64PGh1gEdfjYjJ5Jp+Dfy0D/CLjsLOPTpXzdxwlNMqRj+vPbTcP0nAHgjWv1maDqcEq6u5olw==
dependencies:
"@yr/monotone-cubic-spline" "^1.0.3"
@@ -553,7 +541,7 @@ balanced-match@^1.0.0:
base64-arraybuffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz"
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
binary-extensions@^2.0.0:
@@ -600,7 +588,7 @@ buffer-crc32@^1.0.0:
builtin-modules@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz"
integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
camelcase-css@^2.0.1:
@@ -614,9 +602,9 @@ camelcase@^5.0.0:
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
caniuse-lite@^1.0.30001646:
version "1.0.30001653"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001653.tgz"
integrity sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==
version "1.0.30001703"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz"
integrity sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==
chokidar@^3.4.1, chokidar@^3.5.3:
version "3.6.0"
@@ -680,7 +668,7 @@ cross-spawn@^7.0.0:
css-line-break@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
resolved "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz"
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
dependencies:
utrie "^1.0.2"
@@ -792,7 +780,7 @@ escalade@^3.1.2:
estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
fast-glob@^3.3.0:
@@ -830,7 +818,7 @@ find-up@^4.1.0:
flowbite-datepicker@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/flowbite-datepicker/-/flowbite-datepicker-1.3.0.tgz#60b2423dfa1013e61c50babcf8512501d8b835ee"
resolved "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.0.tgz"
integrity sha512-CLVqzuoE2vkUvWYK/lJ6GzT0be5dlTbH3uuhVwyB67+PjqJWABm2wv68xhBf5BqjpBxvTSQ3mrmLHpPJ2tvrSQ==
dependencies:
"@rollup/plugin-node-resolve" "^15.2.3"
@@ -838,12 +826,12 @@ flowbite-datepicker@^1.3.0:
flowbite-svelte-icons@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/flowbite-svelte-icons/-/flowbite-svelte-icons-1.6.1.tgz#10da0f5aafdbe1f34dcc44293bd2c0020482262b"
resolved "https://registry.npmjs.org/flowbite-svelte-icons/-/flowbite-svelte-icons-1.6.1.tgz"
integrity sha512-Kw/7BzA6fqlFq7tBNudwX0KVU4cbyyXcMcgHTraMwGBtvBQan0RKMbvWwqm4JZNvLGAvRv1BM2EF7rzo/oam1Q==
flowbite-svelte@^0.46.16:
version "0.46.16"
resolved "https://registry.yarnpkg.com/flowbite-svelte/-/flowbite-svelte-0.46.16.tgz#f02dfd3ddf1a4f5cf9c837528ce0dacc34c02944"
resolved "https://registry.npmjs.org/flowbite-svelte/-/flowbite-svelte-0.46.16.tgz"
integrity sha512-NkyMS/d1EwuL1cqstSUflnG9vhhBiNyUiAw51D8lfPKDfUG1iXc4+HueQw01zhHv3uSXRJRToFBrg6npxeJ3jw==
dependencies:
"@floating-ui/dom" "^1.6.10"
@@ -853,7 +841,7 @@ flowbite-svelte@^0.46.16:
flowbite@^2.0.0, flowbite@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/flowbite/-/flowbite-2.5.1.tgz#b2075c6a91b047e7514a41ec4c5437a7eaaf69e0"
resolved "https://registry.npmjs.org/flowbite/-/flowbite-2.5.1.tgz"
integrity sha512-7jP1jy9c3QP7y+KU9lc8ueMkTyUdMDvRP+lteSWgY5TigSZjf9K1kqZxmqjhbx2gBnFQxMl1GAjVThCa8cEpKA==
dependencies:
"@popperjs/core" "^2.9.3"
@@ -880,7 +868,7 @@ fs.realpath@^1.0.0:
fsevents@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
@@ -945,7 +933,7 @@ hasown@^2.0.2:
html2canvas@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
resolved "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz"
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
dependencies:
css-line-break "^2.1.0"
@@ -973,7 +961,7 @@ is-binary-path@~2.1.0:
is-builtin-module@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"
resolved "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz"
integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==
dependencies:
builtin-modules "^3.3.0"
@@ -1004,7 +992,7 @@ is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
is-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz"
integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
is-number@^7.0.0:
@@ -1063,6 +1051,11 @@ lru-cache@^10.2.0:
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
lucide-svelte@^0.479.0:
version "0.479.0"
resolved "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.479.0.tgz"
integrity sha512-epCj6WL86ykxg7oCQTmPEth5e11pwJUzIfG9ROUsWsTP+WPtb3qat+VmAjfx/r4TRW7memTFcbTPvMrZvKthqw==
magic-string@^0.30.3, magic-string@^0.30.5:
version "0.30.11"
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz"
@@ -1095,7 +1088,7 @@ min-indent@^1.0.0:
mini-svg-data-uri@^1.4.3:
version "1.4.4"
resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz"
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
minimatch@^3.1.1:
@@ -1273,7 +1266,7 @@ postcss-import@^15.1.0:
postcss-js@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2"
resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz"
integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==
dependencies:
camelcase-css "^2.0.1"
@@ -1550,40 +1543,40 @@ svelte@^3.54.0:
svg.draggable.js@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz#c514a2f1405efb6f0263e7958f5b68fce50603ba"
resolved "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz"
integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==
dependencies:
svg.js "^2.0.1"
svg.easing.js@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/svg.easing.js/-/svg.easing.js-2.0.0.tgz#8aa9946b0a8e27857a5c40a10eba4091e5691f12"
resolved "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz"
integrity sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==
dependencies:
svg.js ">=2.3.x"
svg.filter.js@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203"
resolved "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz"
integrity sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==
dependencies:
svg.js "^2.2.5"
svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5:
version "2.7.1"
resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d"
resolved "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz"
integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==
svg.pathmorphing.js@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65"
resolved "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz"
integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==
dependencies:
svg.js "^2.4.0"
svg.resize.js@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332"
resolved "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz"
integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==
dependencies:
svg.js "^2.6.5"
@@ -1591,21 +1584,21 @@ svg.resize.js@^1.4.3:
svg.select.js@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73"
resolved "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz"
integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==
dependencies:
svg.js "^2.2.5"
svg.select.js@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917"
resolved "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz"
integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==
dependencies:
svg.js "^2.6.5"
tailwind-merge@^2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.2.tgz#000f05a703058f9f9f3829c644235f81d4c08a1f"
resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz"
integrity sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==
tailwindcss@^3.3.0:
@@ -1638,7 +1631,7 @@ tailwindcss@^3.3.0:
text-segmentation@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
resolved "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz"
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
dependencies:
utrie "^1.0.2"
@@ -1708,11 +1701,6 @@ undici-types@~5.26.4:
resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
undici-types@~6.19.2:
version "6.19.8"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
update-browserslist-db@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz"
@@ -1728,7 +1716,7 @@ util-deprecate@^1.0.2:
utrie@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
resolved "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz"
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
dependencies:
base64-arraybuffer "^1.0.2"