Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73e95d2e5 | ||
|
|
770338a68a | ||
|
|
f58dafbde8 | ||
|
|
004712e851 | ||
|
|
4e53ed2cf8 | ||
|
|
92ccad6253 | ||
|
|
74c5e9bb09 | ||
|
|
2724d6b4d3 | ||
|
|
212e144422 | ||
|
|
205a1b82e7 | ||
|
|
44b4604581 | ||
|
|
3d3454b5a4 | ||
|
|
67f1b04b67 | ||
|
|
fd7d299e55 | ||
|
|
ada492f3f0 | ||
|
|
8a4e4fd32b | ||
|
|
86ced2a217 | ||
|
|
c62251dfe9 | ||
|
|
8bf0f5d36e | ||
|
|
a4b6567947 | ||
|
|
6c5c628bbf | ||
|
|
1d6593340d | ||
|
|
0d992d205f | ||
|
|
f82e79efd4 | ||
|
|
5cdb6b6f75 | ||
|
|
7316a022be | ||
|
|
dbd8a29b73 | ||
|
|
d6a5a02d68 | ||
|
|
9eef00b913 | ||
|
|
3b9dd4824b | ||
|
|
902c1ad39e | ||
|
|
b4f6dea97f | ||
|
|
c1c252f54a | ||
|
|
303b5f8847 | ||
|
|
26f55a463b | ||
|
|
0fa2c366dc | ||
|
|
bf1588e414 | ||
|
|
3be0f25dfc | ||
|
|
0e53028922 | ||
|
|
bc458647a3 | ||
|
|
6c5080394a | ||
|
|
30d45ca2c3 | ||
|
|
614aa3184f | ||
|
|
cacd28bd87 | ||
|
|
a3dfe86a04 | ||
|
|
79c65cab63 | ||
|
|
52237b9385 | ||
|
|
b8d22e92ff | ||
|
|
faac0e29b5 | ||
|
|
899afac910 | ||
|
|
7b2cbfefcc | ||
|
|
793e532240 | ||
|
|
68c7bd251e | ||
|
|
656f82df43 | ||
|
|
1571358f13 | ||
|
|
b6326cf36a | ||
|
|
0b7818a9d3 | ||
|
|
b479d6086b | ||
|
|
6d06491ddc | ||
|
|
e15c6d44a1 | ||
|
|
8ab656a097 | ||
|
|
cb8642b9a9 | ||
|
|
1761d398ee | ||
|
|
eb33fd57a8 | ||
|
|
317b0b373a | ||
|
|
563c8d0085 | ||
|
|
80ced70267 | ||
|
|
3a89b43435 | ||
|
|
d117095a5f | ||
|
|
d78c1bf861 | ||
|
|
20da8034a1 | ||
|
|
0d053a3462 | ||
|
|
280e540f4f | ||
|
|
824cfd23ed | ||
|
|
695728df2e | ||
|
|
24deca75d2 | ||
|
|
8a1184f161 | ||
|
|
d61096d1b1 | ||
|
|
3b9d1be002 | ||
|
|
13262f8f10 | ||
|
|
9f05fc4954 | ||
|
|
3fce06ef63 | ||
|
|
3d13f69e5c | ||
|
|
deb19c6223 | ||
|
|
7466127832 | ||
|
|
af982c5fe0 | ||
|
|
b03f0150d8 | ||
|
|
d61ddafb44 | ||
|
|
fd89a197a5 | ||
|
|
31fa29ee62 | ||
|
|
c7e28b2ad6 | ||
|
|
bbc1343079 | ||
|
|
c7d4fb270b |
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: 提交一个 BUG
|
||||||
|
title: "[BUG]"
|
||||||
|
labels: bug
|
||||||
|
assignees: Xinrea
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**描述:**
|
||||||
|
简要描述一下这个 BUG 的现象
|
||||||
|
|
||||||
|
**如何遇到:**
|
||||||
|
遇到这个 BUG 之前进行了哪些操作
|
||||||
|
|
||||||
|
**期望:**
|
||||||
|
如果执行相同的操作,期望发生什么
|
||||||
|
|
||||||
|
**日志和截图:**
|
||||||
|
如果可以的话,请尽量附上相关截图和日志文件(日志是位于安装目录下,名为 bsr.log 的文件)。
|
||||||
|
|
||||||
|
**相关信息:**
|
||||||
|
- 程序版本:
|
||||||
|
- 系统类型:
|
||||||
|
|
||||||
|
**其他**
|
||||||
|
任何其他想说的
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: 提交一个新功能的建议
|
||||||
|
title: "[feature]"
|
||||||
|
labels: enhancement
|
||||||
|
assignees: Xinrea
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**遇到的问题:**
|
||||||
|
在使用过程中遇到了什么问题让你想要提出建议
|
||||||
|
|
||||||
|
**想要的功能:**
|
||||||
|
想要怎样的新功能来解决这个问题
|
||||||
|
|
||||||
|
**通过什么方式实现(有思路的话):**
|
||||||
|
如果有相关的实现思路或者是参考,可以在此提供
|
||||||
|
|
||||||
|
**其他:**
|
||||||
|
其他任何想说的话
|
||||||
68
README.md
@@ -1,12 +1,68 @@
|
|||||||
# Bilibili ShadowReplay
|
# BiliBili ShadowReplay
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> 程序仍在开发中, Rlease 中提供的下载版本为历史遗留版本, 不保证能够正常使用
|
> v2.0.0 版本为重大更新,将不兼容 v1.x 版本的数据。
|
||||||
|
|
||||||

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

|
||||||
|
|
||||||

|
## 总览
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 直播间管理
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
显示当前缓存的直播间列表,在添加前需要在账号页面添加至少一个账号(主账号)用于直播流以及用户信息的获取。
|
||||||
|
操作菜单包含打开直播流、查看历史记录以及删除等操作。其中历史记录以列表形式展示,可以进行回放以及删除。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
无论是正在进行的直播还是历史录播,都可在预览窗口进行回放,同时也可以进行切片编辑以及投稿。关于预览窗口的相关说明请见 [预览窗口](#预览窗口)。
|
||||||
|
|
||||||
|
## 账号管理
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
程序需要至少一个账号用于直播流以及用户信息的获取,可以在此页面添加账号。
|
||||||
|
|
||||||
|
你可以添加多个账号,但只有一个账号会被标记为主账号,主账号用于直播流的获取。所有账号都可在切片投稿或是观看直播流发送弹幕时自由选择,详情见 [预览窗口](#预览窗口)。
|
||||||
|
|
||||||
|
抖音账号目前仅支持手动 Cookie 添加,且账号仅用于获取直播信息和直播流。
|
||||||
|
|
||||||
|
## 预览窗口
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片、记录时间点以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
|
||||||
|
|
||||||
|
通过预览窗口的快捷键操作,可以快速选择时间区间,进行切片生成以及投稿。
|
||||||
|
|
||||||
|
无论是弹幕发送还是投稿,均可自由选择账号,只要在账号管理中添加了该账号。
|
||||||
|
|
||||||
|
进度条上方会显示弹幕频率图,可以直观地看到弹幕的分布情况;右侧的弹幕统计过滤器可以用于过滤弹幕,只显示含有指定文字的弹幕的统计情况。
|
||||||
|
|
||||||
|
## 封面编辑
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
在预览窗口中,生成切片后可以进行封面编辑,包括关键帧的选择、文字的添加和拖动等。
|
||||||
|
|
||||||
|
## 设置
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。
|
||||||
|
|||||||
78
cliff.toml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# git-cliff ~ default configuration file
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
#
|
||||||
|
# Lines starting with "#" are comments.
|
||||||
|
# Configuration options are organized into tables and keys.
|
||||||
|
# See documentation for more information on available options.
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# template for the changelog header
|
||||||
|
# header = """"""
|
||||||
|
# template for the changelog body
|
||||||
|
# https://keats.github.io/tera/docs/#introduction
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}\
|
||||||
|
## [unreleased]
|
||||||
|
{% endif %}\
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | striptags | trim | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
|
||||||
|
{% if commit.breaking %}[**breaking**] {% endif %}\
|
||||||
|
{{ commit.message | upper_first }} by @{{ commit.author.name }} - {{ commit.id }}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
# template for the changelog footer
|
||||||
|
# footer = """"""
|
||||||
|
# remove the leading and trailing s
|
||||||
|
trim = true
|
||||||
|
# postprocessors
|
||||||
|
postprocessors = [
|
||||||
|
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
|
||||||
|
]
|
||||||
|
# render body even when there are no releases to process
|
||||||
|
# render_always = true
|
||||||
|
# output file path
|
||||||
|
# output = "test.md"
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# parse the commits based on https://www.conventionalcommits.org
|
||||||
|
conventional_commits = true
|
||||||
|
# filter out the commits that are not conventional
|
||||||
|
filter_unconventional = true
|
||||||
|
# process each line of a commit as an individual commit
|
||||||
|
split_commits = false
|
||||||
|
# regex for preprocessing the commit messages
|
||||||
|
commit_preprocessors = [
|
||||||
|
# Replace issue numbers
|
||||||
|
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
|
||||||
|
# Check spelling of the commit with https://github.com/crate-ci/typos
|
||||||
|
# If the spelling is incorrect, it will be automatically fixed.
|
||||||
|
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
|
||||||
|
]
|
||||||
|
# regex for parsing and grouping commits
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
|
||||||
|
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
|
||||||
|
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
|
||||||
|
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
|
||||||
|
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
|
||||||
|
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
|
||||||
|
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
|
||||||
|
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||||
|
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||||
|
{ message = "^chore\\(pr\\)", skip = true },
|
||||||
|
{ message = "^chore\\(pull\\)", skip = true },
|
||||||
|
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
|
||||||
|
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
|
||||||
|
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
|
||||||
|
]
|
||||||
|
# filter out the commits that are not matched by commit parsers
|
||||||
|
filter_commits = false
|
||||||
|
# sort the tags topologically
|
||||||
|
topo_order = false
|
||||||
|
# sort the commits inside sections by oldest/newest order
|
||||||
|
sort_commits = "oldest"
|
||||||
BIN
doc/accounts.png
Normal file
|
After Width: | Height: | Size: 555 KiB |
BIN
doc/archives.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
doc/clip.png
|
Before Width: | Height: | Size: 3.0 MiB |
BIN
doc/coveredit.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 24 KiB |
BIN
doc/header.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
doc/icon.png
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 18 KiB |
BIN
doc/livewindow.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
doc/main.png
|
Before Width: | Height: | Size: 127 KiB |
BIN
doc/output.png
|
Before Width: | Height: | Size: 28 KiB |
BIN
doc/rooms.png
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 1.9 MiB |
BIN
doc/setting.png
|
Before Width: | Height: | Size: 136 KiB |
BIN
doc/settings.png
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
doc/summary.png
Normal file
|
After Width: | Height: | Size: 721 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-cn">
|
<html lang="zh-cn" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
@@ -18,6 +18,38 @@
|
|||||||
height: 12px; /* 设置滑块按钮高度 */
|
height: 12px; /* 设置滑块按钮高度 */
|
||||||
border-radius: 50%; /* 设置为圆形 */
|
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>
|
</style>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
19
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bili-shadowreplay",
|
"name": "bili-shadowreplay",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.4",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -11,20 +11,21 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "2.0.0-rc.0",
|
"@tauri-apps/api": "^2.1.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0-rc.1",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
|
"@tauri-apps/plugin-fs": "~2",
|
||||||
"@tauri-apps/plugin-http": "^2.0.0-rc.2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-notification": "~2",
|
"@tauri-apps/plugin-notification": "~2",
|
||||||
"@tauri-apps/plugin-os": "^2.0.0-rc",
|
"@tauri-apps/plugin-os": "~2",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0-rc.1",
|
"@tauri-apps/plugin-shell": "~2",
|
||||||
"@tauri-apps/plugin-sql": "^2.0.0-rc.1",
|
"@tauri-apps/plugin-sql": "~2",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
"lucide-svelte": "^0.479.0",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "^1.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^2.0.0",
|
"@sveltejs/vite-plugin-svelte": "^2.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.2",
|
"@tauri-apps/cli": "^2.1.0",
|
||||||
"@tsconfig/svelte": "^3.0.0",
|
"@tsconfig/svelte": "^3.0.0",
|
||||||
"@types/node": "^18.7.10",
|
"@types/node": "^18.7.10",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|||||||
BIN
public/imgs/donate.png
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
public/imgs/douyin.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
26
src-tauri/Cargo.lock
generated
@@ -1,6 +1,6 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "addr2line"
|
name = "addr2line"
|
||||||
@@ -316,9 +316,9 @@ checksum = "7a40729d2133846d9ed0ea60a8b9541bccddab49cd30f0715a1da672fe9a2524"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.68"
|
version = "0.1.87"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
|
checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -407,6 +407,8 @@ name = "bili-shadowreplay"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-std",
|
"async-std",
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.21.0",
|
||||||
"chrono",
|
"chrono",
|
||||||
"custom_error",
|
"custom_error",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
@@ -417,9 +419,11 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"m3u8-rs",
|
"m3u8-rs",
|
||||||
"md5",
|
"md5",
|
||||||
|
"mime_guess",
|
||||||
"notify-rust",
|
"notify-rust",
|
||||||
"pct-str",
|
"pct-str",
|
||||||
"platform-dirs",
|
"platform-dirs",
|
||||||
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.11.15",
|
"reqwest 0.11.15",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2818,6 +2822,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -5985,6 +5999,12 @@ dependencies = [
|
|||||||
"unic-common",
|
"unic-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.12"
|
version = "0.3.12"
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ edition = "2021"
|
|||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.0.1", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0.1", features = ["protocol-asset", "tray-icon"] }
|
tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||||
serde_derive = "1.0.158"
|
serde_derive = "1.0.158"
|
||||||
@@ -39,14 +39,18 @@ urlencoding = "2.1.3"
|
|||||||
log = "0.4.22"
|
log = "0.4.22"
|
||||||
simplelog = "0.12.2"
|
simplelog = "0.12.2"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||||
tauri-plugin-dialog = "2.0.1"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-shell = "2.0.1"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-fs = "2.0.1"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-http = "2.0.1"
|
tauri-plugin-http = "2"
|
||||||
tauri-utils = "2.0.1"
|
tauri-utils = "2"
|
||||||
tauri-plugin-sql = { version = "2.0.1", features = ["sqlite"] }
|
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-os = "2.0.1"
|
tauri-plugin-os = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
rand = "0.8.5"
|
||||||
|
base64 = "0.21"
|
||||||
|
mime_guess = "2.0"
|
||||||
|
async-trait = "0.1.87"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||||
@@ -54,4 +58,4 @@ tauri-plugin-notification = "2"
|
|||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2.0.1"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -48,6 +48,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "https://*.afdiancdn.com/"
|
"url": "https://*.afdiancdn.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://*.douyin.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://*.douyinpic.com/"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -57,6 +63,12 @@
|
|||||||
"http:default",
|
"http:default",
|
||||||
"sql:default",
|
"sql:default",
|
||||||
"os:default",
|
"os:default",
|
||||||
"notification:default"
|
"notification:default",
|
||||||
|
"dialog:default",
|
||||||
|
"fs:default",
|
||||||
|
"http:default",
|
||||||
|
"shell:default",
|
||||||
|
"sql:default",
|
||||||
|
"os:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"]}}
|
||||||
81
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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) {
|
||||||
|
// Copy all files in cache to new cache
|
||||||
|
if self.cache == path {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let old_cache = self.cache.clone();
|
||||||
|
crate::handlers::utils::copy_dir_all(old_cache, path).unwrap();
|
||||||
|
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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src-tauri/src/database/account.rs
Normal 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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src-tauri/src/database/message.rs
Normal 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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src-tauri/src/database/record.rs
Normal 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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src-tauri/src/database/recorder.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src-tauri/src/database/video.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,440 +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
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
92
src-tauri/src/handlers/account.rs
Normal 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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src-tauri/src/handlers/config.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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();
|
||||||
|
// 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?;
|
||||||
|
if let Err(e) = crate::handlers::utils::copy_dir_all(&old_cache_path, &cache_path) {
|
||||||
|
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 old cache
|
||||||
|
if old_cache_path != cache_path {
|
||||||
|
if let Err(e) = std::fs::remove_dir_all(old_cache_path) {
|
||||||
|
println!("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 let Err(e) = crate::handlers::utils::copy_dir_all(&old_output_path, &output_path) {
|
||||||
|
log::error!("Copy old output to new output error: {}", e);
|
||||||
|
}
|
||||||
|
config.set_output_path(&output_path);
|
||||||
|
// remove old output
|
||||||
|
if old_output_path != output_path {
|
||||||
|
if let Err(e) = std::fs::remove_dir_all(old_output_path) {
|
||||||
|
log::error!("Remove old output error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(())
|
||||||
|
}
|
||||||
18
src-tauri/src/handlers/message.rs
Normal 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?)
|
||||||
|
}
|
||||||
21
src-tauri/src/handlers/mod.rs
Normal 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,
|
||||||
|
}
|
||||||
179
src-tauri/src/handlers/recorder.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
use crate::database::record::RecordRow;
|
||||||
|
use crate::database::recorder::RecorderRow;
|
||||||
|
use crate::recorder::danmu::DanmuEntry;
|
||||||
|
use crate::recorder::RecorderInfo;
|
||||||
|
use crate::recorder::PlatformType;
|
||||||
|
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 => {
|
||||||
|
let account = state
|
||||||
|
.db
|
||||||
|
.get_account("bilibili",state.config.read().await.primary_uid)
|
||||||
|
.await?;
|
||||||
|
if state.config.read().await.webid_expired() {
|
||||||
|
state.config.write().await.webid = state.client.fetch_webid(&account).await?;
|
||||||
|
state.config.write().await.webid_ts = chrono::Utc::now().timestamp();
|
||||||
|
log::info!("Webid expired, refetching");
|
||||||
|
}
|
||||||
|
Some(account)
|
||||||
|
}
|
||||||
|
PlatformType::Douyin => {
|
||||||
|
let account = state.db.get_account_by_platform("douyin").await?;
|
||||||
|
Some(account)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(account) = 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(e.to_string()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err("No account available".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src-tauri/src/handlers/utils.rs
Normal 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(())
|
||||||
|
}
|
||||||
176
src-tauri/src/handlers/video.rs
Normal 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?)
|
||||||
|
}
|
||||||
@@ -1,780 +1,74 @@
|
|||||||
pub mod bilibili;
|
pub mod bilibili;
|
||||||
use async_std::{fs, stream::StreamExt};
|
pub mod douyin;
|
||||||
use bilibili::{errors::BiliClientError, RoomInfo};
|
pub mod danmu;
|
||||||
use bilibili::{BiliClient, UserInfo};
|
pub mod errors;
|
||||||
use chrono::prelude::*;
|
|
||||||
use custom_error::custom_error;
|
|
||||||
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
|
|
||||||
use ffmpeg_sidecar::{
|
|
||||||
command::FfmpegCommand,
|
|
||||||
event::{FfmpegEvent, LogLevel},
|
|
||||||
};
|
|
||||||
use futures::future::join_all;
|
|
||||||
use m3u8_rs::Playlist;
|
|
||||||
use regex::Regex;
|
|
||||||
use tauri_plugin_notification::NotificationExt;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread;
|
|
||||||
use tauri::{AppHandle, Emitter};
|
|
||||||
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
|
||||||
use tokio::sync::{Mutex, RwLock};
|
|
||||||
|
|
||||||
use crate::db::{AccountRow, Database, DatabaseError, RecordRow};
|
mod entry;
|
||||||
use crate::Config;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
use async_trait::async_trait;
|
||||||
pub struct TsEntry {
|
use danmu::DanmuEntry;
|
||||||
pub url: String,
|
|
||||||
pub sequence: u64,
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub _length: f64,
|
pub enum PlatformType {
|
||||||
pub size: u64,
|
BiliBili,
|
||||||
|
Douyin,
|
||||||
|
Huya,
|
||||||
|
Youtube,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A recorder for BiliBili live streams
|
impl PlatformType {
|
||||||
///
|
pub fn as_str(&self) -> &'static str {
|
||||||
/// This recorder fetches, caches and serves TS entries, currently supporting only StreamType::FMP4.
|
match self {
|
||||||
/// As high-quality streams are accessible only to logged-in users, the use of a BiliClient, which manages cookies, is required.
|
PlatformType::BiliBili => "bilibili",
|
||||||
// TODO implement StreamType::TS
|
PlatformType::Douyin => "douyin",
|
||||||
#[derive(Clone)]
|
PlatformType::Huya => "huya",
|
||||||
pub struct BiliRecorder {
|
PlatformType::Youtube => "youtube",
|
||||||
app_handle: AppHandle,
|
}
|
||||||
client: Arc<RwLock<BiliClient>>,
|
}
|
||||||
db: Arc<Database>,
|
|
||||||
account: AccountRow,
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
config: Arc<RwLock<Config>>,
|
match s {
|
||||||
|
"bilibili" => Some(PlatformType::BiliBili),
|
||||||
|
"douyin" => Some(PlatformType::Douyin),
|
||||||
|
"huya" => Some(PlatformType::Huya),
|
||||||
|
"youtube" => Some(PlatformType::Youtube),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
|
||||||
|
pub struct RecorderInfo {
|
||||||
pub room_id: u64,
|
pub room_id: u64,
|
||||||
pub room_info: Arc<RwLock<RoomInfo>>,
|
pub room_info: RoomInfo,
|
||||||
pub user_info: Arc<RwLock<UserInfo>>,
|
pub user_info: UserInfo,
|
||||||
pub m3u8_url: Arc<RwLock<String>>,
|
pub total_length: f64,
|
||||||
pub live_status: Arc<RwLock<bool>>,
|
pub current_live_id: String,
|
||||||
pub last_sequence: Arc<RwLock<u64>>,
|
pub live_status: bool,
|
||||||
pub ts_length: Arc<RwLock<f64>>,
|
pub platform: String,
|
||||||
pub timestamp: Arc<RwLock<u64>>,
|
|
||||||
ts_entries: Arc<Mutex<Vec<TsEntry>>>,
|
|
||||||
quit: Arc<Mutex<bool>>,
|
|
||||||
header: Arc<RwLock<Option<TsEntry>>>,
|
|
||||||
stream_type: Arc<RwLock<StreamType>>,
|
|
||||||
cache_size: Arc<RwLock<u64>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
|
||||||
pub enum StreamType {
|
pub struct RoomInfo {
|
||||||
TS,
|
pub room_id: u64,
|
||||||
FMP4,
|
pub room_title: String,
|
||||||
|
pub room_cover: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
custom_error! {pub RecorderError
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
|
||||||
NotStarted = "Room is offline",
|
pub struct UserInfo {
|
||||||
EmptyCache = "Cache is empty",
|
pub user_id: String,
|
||||||
M3u8ParseFailed = "Parse m3u8 content failed",
|
pub user_name: String,
|
||||||
InvalidM3u8Url {url: String} = "Invalid m3u8 url: {url}",
|
pub user_avatar: String,
|
||||||
EmptyHeader = "Header url is empty",
|
|
||||||
InvalidTimestamp = "Header timestamp is invalid",
|
|
||||||
InvalidPlaylist = "Invalid m3u8 playlist",
|
|
||||||
InvalidDBOP {err: DatabaseError } = "Database error {err}",
|
|
||||||
ClientError {err: BiliClientError} = "BiliClient fetch failed {err}",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<DatabaseError> for RecorderError {
|
#[async_trait]
|
||||||
fn from(value: DatabaseError) -> Self {
|
pub trait Recorder: Send + Sync + 'static {
|
||||||
RecorderError::InvalidDBOP { err: value }
|
async fn run(&self);
|
||||||
}
|
async fn stop(&self);
|
||||||
}
|
async fn clip_range(&self, live_id: &str, x: f64, y: f64, output_path: &str) -> Result<String, errors::RecorderError>;
|
||||||
|
async fn m3u8_content(&self, live_id: &str) -> String;
|
||||||
impl From<BiliClientError> for RecorderError {
|
async fn info(&self) -> RecorderInfo;
|
||||||
fn from(value: BiliClientError) -> Self {
|
async fn comments(&self, live_id: &str) -> Result<Vec<DanmuEntry>, errors::RecorderError>;
|
||||||
RecorderError::ClientError { err: value }
|
async fn is_recording(&self, live_id: &str) -> bool;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BiliRecorder {
|
|
||||||
pub async fn new(
|
|
||||||
app_handle: AppHandle,
|
|
||||||
webid: &str,
|
|
||||||
db: &Arc<Database>,
|
|
||||||
room_id: u64,
|
|
||||||
account: &AccountRow,
|
|
||||||
config: Arc<RwLock<Config>>,
|
|
||||||
) -> Result<Self, RecorderError> {
|
|
||||||
let client = BiliClient::new()?;
|
|
||||||
let room_info = client.get_room_info(account, room_id).await?;
|
|
||||||
let user_info = client
|
|
||||||
.get_user_info(webid, account, room_info.user_id)
|
|
||||||
.await?;
|
|
||||||
let mut m3u8_url = String::from("");
|
|
||||||
let mut live_status = false;
|
|
||||||
let mut stream_type = StreamType::FMP4;
|
|
||||||
if room_info.live_status == 1 {
|
|
||||||
live_status = true;
|
|
||||||
if let Ok((index_url, stream_type_now)) =
|
|
||||||
client.get_play_url(account, room_info.room_id).await
|
|
||||||
{
|
|
||||||
m3u8_url = index_url;
|
|
||||||
stream_type = stream_type_now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let recorder = Self {
|
|
||||||
app_handle,
|
|
||||||
client: Arc::new(RwLock::new(client)),
|
|
||||||
db: db.clone(),
|
|
||||||
account: account.clone(),
|
|
||||||
config,
|
|
||||||
room_id,
|
|
||||||
room_info: Arc::new(RwLock::new(room_info)),
|
|
||||||
user_info: Arc::new(RwLock::new(user_info)),
|
|
||||||
m3u8_url: Arc::new(RwLock::new(m3u8_url)),
|
|
||||||
live_status: Arc::new(RwLock::new(live_status)),
|
|
||||||
last_sequence: Arc::new(RwLock::new(0)),
|
|
||||||
ts_length: Arc::new(RwLock::new(0.0)),
|
|
||||||
ts_entries: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
timestamp: Arc::new(RwLock::new(0)),
|
|
||||||
quit: Arc::new(Mutex::new(false)),
|
|
||||||
header: Arc::new(RwLock::new(None)),
|
|
||||||
stream_type: Arc::new(RwLock::new(stream_type)),
|
|
||||||
cache_size: Arc::new(RwLock::new(0)),
|
|
||||||
};
|
|
||||||
log::info!("Recorder for room {} created.", room_id);
|
|
||||||
Ok(recorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reset(&self) {
|
|
||||||
*self.ts_length.write().await = 0.0;
|
|
||||||
*self.last_sequence.write().await = 0;
|
|
||||||
self.ts_entries.lock().await.clear();
|
|
||||||
*self.header.write().await = None;
|
|
||||||
*self.timestamp.write().await = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_status(&self) -> bool {
|
|
||||||
if let Ok(room_info) = self
|
|
||||||
.client
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get_room_info(&self.account, self.room_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
*self.room_info.write().await = room_info.clone();
|
|
||||||
let live_status = room_info.live_status == 1;
|
|
||||||
|
|
||||||
// handle live notification
|
|
||||||
if *self.live_status.read().await != live_status {
|
|
||||||
if live_status {
|
|
||||||
if self.config.read().await.live_start_notify {
|
|
||||||
self.app_handle
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.title("BiliShadowReplay - 直播开始")
|
|
||||||
.body(format!("{} 开启了直播:{}",self.user_info.read().await.user_name, room_info.room_title)).show().unwrap();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if self.config.read().await.live_end_notify {
|
|
||||||
self.app_handle
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.title("BiliShadowReplay - 直播结束")
|
|
||||||
.body(format!("{} 的直播结束了",self.user_info.read().await.user_name)).show().unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// if stream is confirmed to be closed, live stream cache is cleaned.
|
|
||||||
// all request will go through fs
|
|
||||||
if live_status {
|
|
||||||
if let Ok((index_url, stream_type)) = self
|
|
||||||
.client
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get_play_url(&self.account, self.room_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
self.m3u8_url.write().await.replace_range(.., &index_url);
|
|
||||||
*self.stream_type.write().await = stream_type;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.reset().await;
|
|
||||||
}
|
|
||||||
*self.live_status.write().await = live_status;
|
|
||||||
live_status
|
|
||||||
} else {
|
|
||||||
*self.live_status.write().await = true;
|
|
||||||
// may encouter internet issues, not sure whether the stream is closed
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_archives(&self) -> Result<Vec<RecordRow>, RecorderError> {
|
|
||||||
Ok(self.db.get_records(self.room_id).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_archive(&self, live_id: u64) -> Result<RecordRow, RecorderError> {
|
|
||||||
Ok(self.db.get_record(self.room_id, live_id).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_archive(&self, ts: u64) {
|
|
||||||
if let Err(e) = self.db.remove_record(ts).await {
|
|
||||||
log::error!("remove archive failed: {}", e);
|
|
||||||
} else {
|
|
||||||
let target_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
|
|
||||||
if fs::remove_dir_all(target_dir).await.is_err() {
|
|
||||||
log::error!("remove archive failed [{}]{}", self.room_id, ts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(&self) {
|
|
||||||
let self_clone = self.clone();
|
|
||||||
thread::spawn(move || {
|
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
|
||||||
runtime.block_on(async move {
|
|
||||||
while !*self_clone.quit.lock().await {
|
|
||||||
if self_clone.check_status().await {
|
|
||||||
// Live status is ok, start recording.
|
|
||||||
while !*self_clone.quit.lock().await {
|
|
||||||
if let Err(e) = self_clone.update_entries().await {
|
|
||||||
log::error!("update entries error: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
thread::sleep(std::time::Duration::from_secs(1));
|
|
||||||
}
|
|
||||||
// go check status again
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Every 10s check live status.
|
|
||||||
thread::sleep(std::time::Duration::from_secs(10));
|
|
||||||
}
|
|
||||||
log::info!("recording thread {} quit.", self_clone.room_id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Thread for danmaku
|
|
||||||
let self_clone = self.clone();
|
|
||||||
thread::spawn(move || {
|
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
|
||||||
runtime.block_on(async move {
|
|
||||||
self_clone.danmu().await;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn danmu(&self) {
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
|
||||||
let cookies = self.account.cookies.clone();
|
|
||||||
let uid: u64 = self.account.uid;
|
|
||||||
let ws = ws_socket_object(tx, uid, self.room_id, cookies.as_str());
|
|
||||||
if let Err(e) = tokio::select! {v = ws => v, v = self.recv(self.room_id,rx) => v} {
|
|
||||||
log::debug!("{}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn recv(
|
|
||||||
&self,
|
|
||||||
room: u64,
|
|
||||||
mut rx: UnboundedReceiver<WsStreamMessageType>,
|
|
||||||
) -> Result<(), FelgensError> {
|
|
||||||
while let Some(msg) = rx.recv().await {
|
|
||||||
if let WsStreamMessageType::DanmuMsg(msg) = msg {
|
|
||||||
self.app_handle
|
|
||||||
.emit(&format!("danmu:{}", room), msg.msg.clone())
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_playlist(&self) -> Result<Playlist, RecorderError> {
|
|
||||||
let url = self.m3u8_url.read().await.clone();
|
|
||||||
let mut index_content = self.client.read().await.get_index_content(&url).await?;
|
|
||||||
if index_content.contains("Not Found") {
|
|
||||||
// 404 try another time after update
|
|
||||||
if self.check_status().await {
|
|
||||||
index_content = self.client.read().await.get_index_content(&url).await?;
|
|
||||||
} else {
|
|
||||||
return Err(RecorderError::NotStarted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m3u8_rs::parse_playlist_res(index_content.as_bytes())
|
|
||||||
.map_err(|_| RecorderError::M3u8ParseFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_header_url(&self) -> Result<String, RecorderError> {
|
|
||||||
let url = self.m3u8_url.read().await.clone();
|
|
||||||
let mut index_content = self.client.read().await.get_index_content(&url).await?;
|
|
||||||
if index_content.contains("Not Found") {
|
|
||||||
// 404 try another time after update
|
|
||||||
log::warn!("Index content not found: {}", index_content);
|
|
||||||
if self.check_status().await {
|
|
||||||
index_content = self.client.read().await.get_index_content(&url).await?;
|
|
||||||
} else {
|
|
||||||
return Err(RecorderError::NotStarted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if index_content.contains("BANDWIDTH") {
|
|
||||||
// this index content provides another m3u8 url
|
|
||||||
let new_url = index_content.lines().last().unwrap();
|
|
||||||
*self.m3u8_url.write().await = String::from(new_url);
|
|
||||||
return Box::pin(self.get_header_url()).await;
|
|
||||||
}
|
|
||||||
let mut header_url = String::from("");
|
|
||||||
let re = Regex::new(r"h.*\.m4s").unwrap();
|
|
||||||
if let Some(captures) = re.captures(&index_content) {
|
|
||||||
header_url = captures.get(0).unwrap().as_str().to_string();
|
|
||||||
}
|
|
||||||
if header_url.is_empty() {
|
|
||||||
log::warn!("Parse header url failed: {}", index_content);
|
|
||||||
}
|
|
||||||
Ok(header_url)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ts_url(&self, ts_url: &String) -> Result<String, RecorderError> {
|
|
||||||
// Construct url for ts and fmp4 stream.
|
|
||||||
match *self.stream_type.read().await {
|
|
||||||
StreamType::TS => {
|
|
||||||
let url = self.m3u8_url.read().await.clone();
|
|
||||||
if let Some(pos) = url.rfind("index.m3u8") {
|
|
||||||
Ok(format!("{}{}", &url[..pos], ts_url))
|
|
||||||
} else {
|
|
||||||
Err(RecorderError::InvalidM3u8Url { url })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StreamType::FMP4 => {
|
|
||||||
let url = self.m3u8_url.read().await.clone();
|
|
||||||
if let Some(pos) = url.rfind("index.m3u8") {
|
|
||||||
Ok(format!("{}{}", &url[..pos], ts_url))
|
|
||||||
} else {
|
|
||||||
Err(RecorderError::InvalidM3u8Url { url })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn extract_timestamp(&self, header_url: &str) -> u64 {
|
|
||||||
log::debug!("[{}]Extract timestamp from {}", self.room_id, header_url);
|
|
||||||
let re = Regex::new(r"h(\d+).m4s").unwrap();
|
|
||||||
if let Some(cap) = re.captures(header_url) {
|
|
||||||
let ts = cap.get(1).unwrap().as_str().parse().unwrap();
|
|
||||||
*self.timestamp.write().await = ts;
|
|
||||||
ts
|
|
||||||
} else {
|
|
||||||
log::error!("Extract timestamp failed: {}", header_url);
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_entries(&self) -> Result<(), RecorderError> {
|
|
||||||
let parsed = self.get_playlist().await;
|
|
||||||
let mut timestamp = *self.timestamp.read().await;
|
|
||||||
let mut work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
|
|
||||||
// Check header if None
|
|
||||||
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
|
|
||||||
{
|
|
||||||
// Get url from EXT-X-MAP
|
|
||||||
let header_url = self.get_header_url().await?;
|
|
||||||
if header_url.is_empty() {
|
|
||||||
return Err(RecorderError::EmptyHeader);
|
|
||||||
}
|
|
||||||
timestamp = self.extract_timestamp(&header_url).await;
|
|
||||||
if timestamp == 0 {
|
|
||||||
log::error!("[{}]Parse timestamp failed: {}", self.room_id, header_url);
|
|
||||||
return Err(RecorderError::InvalidTimestamp);
|
|
||||||
}
|
|
||||||
self.db
|
|
||||||
.add_record(
|
|
||||||
timestamp,
|
|
||||||
self.room_id,
|
|
||||||
&self.room_info.read().await.room_title,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
// now work dir is confirmed
|
|
||||||
work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
|
|
||||||
// if folder is exisited, need to load previous data into cache
|
|
||||||
if let Ok(meta) = fs::metadata(&work_dir).await {
|
|
||||||
if meta.is_dir() {
|
|
||||||
log::warn!("Live {} is already cached. Try to restore", timestamp);
|
|
||||||
self.restore(&work_dir).await;
|
|
||||||
} else {
|
|
||||||
// make sure work_dir is created
|
|
||||||
fs::create_dir_all(&work_dir).await.unwrap();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// make sure work_dir is created
|
|
||||||
fs::create_dir_all(&work_dir).await.unwrap();
|
|
||||||
}
|
|
||||||
let full_header_url = self.ts_url(&header_url).await?;
|
|
||||||
let mut header = TsEntry {
|
|
||||||
url: full_header_url.clone(),
|
|
||||||
sequence: 0,
|
|
||||||
_length: 0.0,
|
|
||||||
size: 0,
|
|
||||||
};
|
|
||||||
let file_name = header_url.split('/').last().unwrap();
|
|
||||||
// Download header
|
|
||||||
match self
|
|
||||||
.client
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.download_ts(&full_header_url, &format!("{}/{}", work_dir, file_name))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(size) => {
|
|
||||||
header.size = size;
|
|
||||||
*self.header.write().await = Some(header);
|
|
||||||
// add size into cache_size
|
|
||||||
*self.cache_size.write().await += size;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Download header failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match parsed {
|
|
||||||
Ok(Playlist::MasterPlaylist(pl)) => log::debug!("Master playlist:\n{:?}", pl),
|
|
||||||
Ok(Playlist::MediaPlaylist(pl)) => {
|
|
||||||
let mut sequence = pl.media_sequence;
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
for ts in pl.segments {
|
|
||||||
if sequence <= *self.last_sequence.read().await {
|
|
||||||
sequence += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut ts_entry = TsEntry {
|
|
||||||
url: ts.uri,
|
|
||||||
sequence,
|
|
||||||
_length: ts.duration as f64,
|
|
||||||
size: 0,
|
|
||||||
};
|
|
||||||
let client = self.client.clone();
|
|
||||||
let ts_url = self.ts_url(&ts_entry.url).await?;
|
|
||||||
ts_entry.url = ts_url.clone();
|
|
||||||
if ts_url.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let work_dir = work_dir.clone();
|
|
||||||
let cache_size_clone = self.cache_size.clone();
|
|
||||||
handles.push(tokio::task::spawn(async move {
|
|
||||||
let ts_url_clone = ts_url.clone();
|
|
||||||
let file_name = ts_url_clone.split('/').last().unwrap();
|
|
||||||
match client
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(size) => {
|
|
||||||
*cache_size_clone.write().await += size;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Download ts failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
let mut entries = self.ts_entries.lock().await;
|
|
||||||
entries.push(ts_entry);
|
|
||||||
*self.last_sequence.write().await = sequence;
|
|
||||||
let mut total_length = self.ts_length.write().await;
|
|
||||||
*total_length += ts.duration as f64;
|
|
||||||
sequence += 1;
|
|
||||||
}
|
|
||||||
join_all(handles).await.into_iter().for_each(|e| {
|
|
||||||
if let Err(e) = e {
|
|
||||||
log::error!("download ts failed: {:?}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// currently we take every segement's length as 1.0s.
|
|
||||||
self.db
|
|
||||||
.update_record(
|
|
||||||
timestamp,
|
|
||||||
self.ts_entries.lock().await.len() as i64,
|
|
||||||
*self.cache_size.read().await,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return Err(RecorderError::InvalidPlaylist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restore(&self, work_dir: &str) {
|
|
||||||
// by the way, header will be set after restore, so we don't need to restore it.
|
|
||||||
let entries = self.get_fs_entries(work_dir).await;
|
|
||||||
if entries.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.ts_entries.lock().await.extend_from_slice(&entries);
|
|
||||||
*self.ts_length.write().await = entries.len() as f64;
|
|
||||||
*self.cache_size.write().await = entries.iter().map(|e| e.size).sum();
|
|
||||||
*self.last_sequence.write().await = entries.last().unwrap().sequence;
|
|
||||||
log::info!("Restore {} entries from local file", entries.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clip(&self, ts: u64, d: f64, output_path: &str) -> Result<String, RecorderError> {
|
|
||||||
let total_length = *self.ts_length.read().await;
|
|
||||||
self.clip_range(ts, total_length - d, total_length, output_path)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// x and y are relative to first sequence
|
|
||||||
pub async fn clip_range(
|
|
||||||
&self,
|
|
||||||
ts: u64,
|
|
||||||
x: f64,
|
|
||||||
y: f64,
|
|
||||||
output_path: &str,
|
|
||||||
) -> Result<String, RecorderError> {
|
|
||||||
if *self.timestamp.read().await == ts {
|
|
||||||
self.clip_live_range(x, y, output_path).await
|
|
||||||
} else {
|
|
||||||
self.clip_archive_range(ts, x, y, output_path).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clip_archive_range(
|
|
||||||
&self,
|
|
||||||
ts: u64,
|
|
||||||
x: f64,
|
|
||||||
y: f64,
|
|
||||||
output_path: &str,
|
|
||||||
) -> Result<String, RecorderError> {
|
|
||||||
log::info!("create archive clip for range [{}, {}]", x, y);
|
|
||||||
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
|
|
||||||
let entries = self.get_fs_entries(&work_dir).await;
|
|
||||||
if entries.is_empty() {
|
|
||||||
return Err(RecorderError::EmptyCache);
|
|
||||||
}
|
|
||||||
let mut file_list = String::new();
|
|
||||||
// header fist
|
|
||||||
file_list += &format!("{}/h{}.m4s", work_dir, ts);
|
|
||||||
file_list += "|";
|
|
||||||
// add body entries
|
|
||||||
let mut offset = 0.0;
|
|
||||||
if !entries.is_empty() {
|
|
||||||
for e in entries {
|
|
||||||
if offset < x {
|
|
||||||
offset += 1.0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
file_list += &format!("{}/{}", work_dir, e.url);
|
|
||||||
file_list += "|";
|
|
||||||
if offset > y {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offset += 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::create_dir_all(output_path).expect("create clips folder failed");
|
|
||||||
let file_name = format!(
|
|
||||||
"{}/[{}]{}_{}_{:.1}.mp4",
|
|
||||||
output_path,
|
|
||||||
self.room_id,
|
|
||||||
ts,
|
|
||||||
Utc::now().format("%m%d%H%M%S"),
|
|
||||||
y - x
|
|
||||||
);
|
|
||||||
log::info!("{}", file_name);
|
|
||||||
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
|
|
||||||
FfmpegCommand::new()
|
|
||||||
.args(args.split(' '))
|
|
||||||
.output(file_name.clone())
|
|
||||||
.spawn()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.unwrap()
|
|
||||||
.for_each(|e| match e {
|
|
||||||
FfmpegEvent::Log(LogLevel::Error, e) => log::error!("Error: {}", e),
|
|
||||||
FfmpegEvent::Progress(p) => log::info!("Progress: {}", p.time),
|
|
||||||
_ => {}
|
|
||||||
});
|
|
||||||
Ok(file_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clip_live_range(
|
|
||||||
&self,
|
|
||||||
x: f64,
|
|
||||||
y: f64,
|
|
||||||
output_path: &str,
|
|
||||||
) -> Result<String, RecorderError> {
|
|
||||||
log::info!("create live clip for range [{}, {}]", x, y);
|
|
||||||
let mut to_combine = Vec::new();
|
|
||||||
let header_copy = self.header.read().await.clone();
|
|
||||||
let entry_copy = self.ts_entries.lock().await.clone();
|
|
||||||
if entry_copy.is_empty() {
|
|
||||||
return Err(RecorderError::EmptyCache);
|
|
||||||
}
|
|
||||||
let mut start = x;
|
|
||||||
let mut end = y;
|
|
||||||
if start > end {
|
|
||||||
std::mem::swap(&mut start, &mut end);
|
|
||||||
}
|
|
||||||
let mut offset = 0.0;
|
|
||||||
for e in entry_copy.iter() {
|
|
||||||
if (offset as f64) < start {
|
|
||||||
offset += 1.0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
to_combine.push(e);
|
|
||||||
if (offset as f64) >= end {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offset += 1.0;
|
|
||||||
}
|
|
||||||
if *self.stream_type.read().await == StreamType::FMP4 {
|
|
||||||
// add header to vec
|
|
||||||
let header = header_copy.as_ref().unwrap();
|
|
||||||
to_combine.insert(0, header);
|
|
||||||
}
|
|
||||||
let mut file_list = String::new();
|
|
||||||
let timestamp = *self.timestamp.read().await;
|
|
||||||
for e in to_combine {
|
|
||||||
let file_name = e.url.split('/').last().unwrap();
|
|
||||||
let file_path = format!(
|
|
||||||
"{}/{}/{}/{}",
|
|
||||||
self.config.read().await.cache, self.room_id, timestamp, file_name
|
|
||||||
);
|
|
||||||
file_list += &file_path;
|
|
||||||
file_list += "|";
|
|
||||||
}
|
|
||||||
let title = self.room_info.read().await.room_title.clone();
|
|
||||||
let title: String = title.chars().take(5).collect();
|
|
||||||
std::fs::create_dir_all(output_path).expect("create clips folder failed");
|
|
||||||
let file_name = format!(
|
|
||||||
"{}/[{}]{}_{}_{:.1}.mp4",
|
|
||||||
output_path,
|
|
||||||
self.room_id,
|
|
||||||
title,
|
|
||||||
Utc::now().format("%m%d%H%M%S"),
|
|
||||||
end - start
|
|
||||||
);
|
|
||||||
log::info!("{}", file_name);
|
|
||||||
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
|
|
||||||
FfmpegCommand::new()
|
|
||||||
.args(args.split(' '))
|
|
||||||
.output(file_name.clone())
|
|
||||||
.spawn()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.unwrap()
|
|
||||||
.for_each(|e| match e {
|
|
||||||
FfmpegEvent::Log(LogLevel::Error, e) => log::error!("Error: {}", e),
|
|
||||||
FfmpegEvent::Progress(p) => log::info!("Progress: {}", p.time),
|
|
||||||
_ => {}
|
|
||||||
});
|
|
||||||
Ok(file_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// timestamp is the id of live stream
|
|
||||||
pub async fn generate_m3u8(&self, timestamp: u64) -> String {
|
|
||||||
if *self.timestamp.read().await == timestamp {
|
|
||||||
self.generate_live_m3u8().await
|
|
||||||
} else {
|
|
||||||
self.generate_archive_m3u8(timestamp).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_archive_m3u8(&self, timestamp: u64) -> String {
|
|
||||||
let mut m3u8_content = "#EXTM3U\n".to_string();
|
|
||||||
m3u8_content += "#EXT-X-VERSION:6\n";
|
|
||||||
m3u8_content += "#EXT-X-TARGETDURATION:1\n";
|
|
||||||
m3u8_content += "#EXT-X-PLAYLIST-TYPE:VOD\n";
|
|
||||||
// add header, FMP4 need this
|
|
||||||
// TODO handle StreamType::TS
|
|
||||||
let header_url = format!("/{}/{}/h{}.m4s", self.room_id, timestamp, timestamp);
|
|
||||||
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
|
|
||||||
// add entries from read_dir
|
|
||||||
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, timestamp);
|
|
||||||
let entries = self.get_fs_entries(&work_dir).await;
|
|
||||||
if entries.is_empty() {
|
|
||||||
return m3u8_content;
|
|
||||||
}
|
|
||||||
let mut last_sequence = entries.first().unwrap().sequence;
|
|
||||||
for e in entries {
|
|
||||||
let current_seq = e.sequence;
|
|
||||||
if current_seq - last_sequence > 1 {
|
|
||||||
m3u8_content += "#EXT-X-DISCONTINUITY\n"
|
|
||||||
}
|
|
||||||
last_sequence = current_seq;
|
|
||||||
m3u8_content += "#EXTINF:1,\n";
|
|
||||||
m3u8_content += &format!("/{}/{}/{}\n", self.room_id, timestamp, e.url);
|
|
||||||
}
|
|
||||||
m3u8_content += "#EXT-X-ENDLIST";
|
|
||||||
m3u8_content
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch HLS segments from local cached file, header is excluded
|
|
||||||
async fn get_fs_entries(&self, path: &str) -> Vec<TsEntry> {
|
|
||||||
let mut ret = Vec::new();
|
|
||||||
let direntry = fs::read_dir(path).await;
|
|
||||||
if direntry.is_err() {
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
let mut direntry = direntry.unwrap();
|
|
||||||
while let Some(e) = direntry.next().await {
|
|
||||||
if e.is_err() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let e = e.unwrap();
|
|
||||||
let etype = e.file_type().await;
|
|
||||||
if etype.is_err() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let etype = etype.unwrap();
|
|
||||||
if !etype.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let file_name = e.file_name().to_str().unwrap().to_string();
|
|
||||||
if file_name.starts_with("h") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ret.push(TsEntry {
|
|
||||||
url: file_name.clone(),
|
|
||||||
sequence: file_name.split('.').next().unwrap().parse().unwrap(),
|
|
||||||
_length: 1.0,
|
|
||||||
size: e.metadata().await.unwrap().len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ret.sort_by(|a, b| a.sequence.cmp(&b.sequence));
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
|
|
||||||
/// if fetching live/last stream m3u8, all entries are cached in memory, so it will be much faster than read_dir
|
|
||||||
async fn generate_live_m3u8(&self) -> String {
|
|
||||||
let live_status = *self.live_status.read().await;
|
|
||||||
let mut m3u8_content = "#EXTM3U\n".to_string();
|
|
||||||
m3u8_content += "#EXT-X-VERSION:6\n";
|
|
||||||
m3u8_content += "#EXT-X-TARGETDURATION:1\n";
|
|
||||||
// if stream is closed, switch to VOD
|
|
||||||
if live_status {
|
|
||||||
m3u8_content += "#EXT-X-PLAYLIST-TYPE:EVENT\n";
|
|
||||||
} else {
|
|
||||||
m3u8_content += "#EXT-X-PLAYLIST-TYPE:VOD\n";
|
|
||||||
}
|
|
||||||
let timestamp = *self.timestamp.read().await;
|
|
||||||
// initial segment for fmp4, info from self.header
|
|
||||||
if let Some(header) = self.header.read().await.as_ref() {
|
|
||||||
let file_name = header.url.split('/').last().unwrap();
|
|
||||||
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
|
|
||||||
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", local_url);
|
|
||||||
}
|
|
||||||
let entries = self.ts_entries.lock().await.clone();
|
|
||||||
if entries.is_empty() {
|
|
||||||
return m3u8_content;
|
|
||||||
}
|
|
||||||
let mut last_sequence = entries.first().unwrap().sequence;
|
|
||||||
for entry in entries.iter() {
|
|
||||||
if entry.sequence - last_sequence > 1 {
|
|
||||||
// discontinuity happens
|
|
||||||
m3u8_content += "#EXT-X-DISCONTINUITY\n"
|
|
||||||
}
|
|
||||||
last_sequence = entry.sequence;
|
|
||||||
m3u8_content += "#EXTINF:1,\n";
|
|
||||||
let file_name = entry.url.split('/').last().unwrap();
|
|
||||||
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
|
|
||||||
m3u8_content += &format!("{}\n", local_url);
|
|
||||||
}
|
|
||||||
// let player know stream is closed
|
|
||||||
if !live_status {
|
|
||||||
m3u8_content += "#EXT-X-ENDLIST";
|
|
||||||
}
|
|
||||||
m3u8_content
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
796
src-tauri/src/recorder/bilibili/client.rs
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
use super::errors::BiliClientError;
|
||||||
|
use super::profile;
|
||||||
|
use super::profile::Profile;
|
||||||
|
use super::response;
|
||||||
|
use base64::Engine;
|
||||||
|
use pct_str::PctString;
|
||||||
|
use pct_str::URIReserved;
|
||||||
|
use regex::Regex;
|
||||||
|
use reqwest::Client;
|
||||||
|
use super::response::Format;
|
||||||
|
use super::response::GeneralResponse;
|
||||||
|
use super::response::PostVideoMetaResponse;
|
||||||
|
use super::response::PreuploadResponse;
|
||||||
|
use super::response::VideoSubmitData;
|
||||||
|
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;
|
||||||
|
use crate::database::account::AccountRow;
|
||||||
|
|
||||||
|
#[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(¶ms)
|
||||||
|
.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(¶ms)
|
||||||
|
.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(¶ms)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ custom_error! {pub BiliClientError
|
|||||||
InvalidUrl = "Invalid url",
|
InvalidUrl = "Invalid url",
|
||||||
InvalidFormat = "Invalid stream format",
|
InvalidFormat = "Invalid stream format",
|
||||||
EmptyCache = "Empty cache",
|
EmptyCache = "Empty cache",
|
||||||
ClientError{err: reqwest::Error} = "Client error",
|
ClientError{err: reqwest::Error} = "Client error: {err}",
|
||||||
IOError{err: std::io::Error} = "IO error",
|
IOError{err: std::io::Error} = "IO error: {err}",
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for BiliClientError {
|
impl From<reqwest::Error> for BiliClientError {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct GeneralResponse {
|
pub struct GeneralResponse {
|
||||||
@@ -13,6 +15,8 @@ pub struct GeneralResponse {
|
|||||||
pub enum Data {
|
pub enum Data {
|
||||||
VideoSubmit(VideoSubmitData),
|
VideoSubmit(VideoSubmitData),
|
||||||
Cover(CoverData),
|
Cover(CoverData),
|
||||||
|
RoomPlayInfo(RoomPlayInfoData),
|
||||||
|
VideoTypeList(VideoTypeListData),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
@@ -41,3 +45,183 @@ pub struct PostVideoMetaResponse {
|
|||||||
pub key: String,
|
pub key: String,
|
||||||
pub upload_id: 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,
|
||||||
|
}
|
||||||
|
|||||||
71
src-tauri/src/recorder/danmu.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::{
|
||||||
|
fs::{File, OpenOptions},
|
||||||
|
io::{AsyncBufReadExt, BufReader},
|
||||||
|
sync::RwLock,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct DanmuEntry {
|
||||||
|
pub ts: u64,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DanmuStorage {
|
||||||
|
cache: RwLock<Vec<DanmuEntry>>,
|
||||||
|
file: RwLock<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DanmuStorage {
|
||||||
|
pub async fn new(file_path: &str) -> Option<DanmuStorage> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(file_path)
|
||||||
|
.await;
|
||||||
|
if file.is_err() {
|
||||||
|
log::error!("Open danmu file failed: {}", file.err().unwrap());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let file = file.unwrap();
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
let mut preload_cache: Vec<DanmuEntry> = Vec::new();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let parts: Vec<&str> = line.split(':').collect();
|
||||||
|
let ts: u64 = parts[0].parse().unwrap();
|
||||||
|
let content = parts[1].to_string();
|
||||||
|
preload_cache.push(DanmuEntry { ts, content })
|
||||||
|
}
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.create(true)
|
||||||
|
.open(file_path)
|
||||||
|
.await
|
||||||
|
.expect("create danmu.txt failed");
|
||||||
|
Some(DanmuStorage {
|
||||||
|
cache: RwLock::new(preload_cache),
|
||||||
|
file: RwLock::new(file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_line(&self, ts: u64, content: &str) {
|
||||||
|
self.cache.write().await.push(DanmuEntry {
|
||||||
|
ts,
|
||||||
|
content: content.to_string(),
|
||||||
|
});
|
||||||
|
let _ = self
|
||||||
|
.file
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.write(format!("{}:{}\n", ts, content).as_bytes())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_entries(&self) -> Vec<DanmuEntry> {
|
||||||
|
self.cache.read().await.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
549
src-tauri/src/recorder/douyin.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src-tauri/src/recorder/douyin/client.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
592
src-tauri/src/recorder/douyin/response.rs
Normal 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,
|
||||||
|
}
|
||||||
149
src-tauri/src/recorder/entry.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src-tauri/src/recorder/errors.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use custom_error::custom_error;
|
||||||
|
use super::bilibili::client::BiliStream;
|
||||||
|
use super::douyin::client::DouyinClientError;
|
||||||
|
|
||||||
|
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}",
|
||||||
|
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}",
|
||||||
|
}
|
||||||
0
src-tauri/src/recorder/ts.rs
Normal file
@@ -1,14 +1,20 @@
|
|||||||
use crate::db::{AccountRow, Database, RecordRow};
|
use crate::config::Config;
|
||||||
use crate::recorder::bilibili::UserInfo;
|
use crate::database::DatabaseError;
|
||||||
use crate::recorder::RecorderError;
|
use crate::database::{account::AccountRow, record::RecordRow, Database};
|
||||||
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
|
use crate::recorder::bilibili::BiliRecorder;
|
||||||
use crate::Config;
|
use crate::recorder::danmu::DanmuEntry;
|
||||||
|
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 custom_error::custom_error;
|
||||||
use dashmap::DashMap;
|
use hyper::Method;
|
||||||
use hyper::{
|
use hyper::{
|
||||||
service::{make_service_fn, service_fn},
|
service::{make_service_fn, service_fn},
|
||||||
Body, Request, Response, Server,
|
Body, Request, Response, Server,
|
||||||
};
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::{convert::Infallible, sync::Arc};
|
use std::{convert::Infallible, sync::Arc};
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
@@ -20,29 +26,23 @@ pub struct RecorderList {
|
|||||||
pub recorders: Vec<RecorderInfo>,
|
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 {
|
pub struct RecorderManager {
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
|
db: Arc<Database>,
|
||||||
config: Arc<RwLock<Config>>,
|
config: Arc<RwLock<Config>>,
|
||||||
recorders: Arc<DashMap<u64, BiliRecorder>>,
|
recorders: Arc<RwLock<HashMap<String, Box<dyn Recorder>>>>,
|
||||||
hls_server_addr: Arc<RwLock<Option<SocketAddr>>>,
|
hls_server_addr: Arc<RwLock<Option<SocketAddr>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
custom_error! {pub RecorderManagerError
|
custom_error! {pub RecorderManagerError
|
||||||
AlreadyExisted { room_id: u64 } = "Recorder {room_id} already existed",
|
AlreadyExisted { room_id: u64 } = "Recorder {room_id} already existed",
|
||||||
NotFound {room_id: u64 } = "Recorder {room_id} not found",
|
NotFound {room_id: u64 } = "Recorder {room_id} not found",
|
||||||
RecorderError { err: RecorderError } = "Recorder error",
|
InvalidPlatformType { platform: String } = "Invalid platform type: {platform}",
|
||||||
IOError {err: std::io::Error } = "IO error",
|
RecorderError { err: RecorderError } = "Recorder error: {err}",
|
||||||
HLSError { err: hyper::Error } = "HLS server error",
|
IOError {err: std::io::Error } = "IO error: {err}",
|
||||||
|
HLSError { err: hyper::Error } = "HLS server error: {err}",
|
||||||
|
DatabaseError { err: DatabaseError } = "Database error: {err}",
|
||||||
|
Recording { live_id: String } = "无法删除正在录制的直播 {live_id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<hyper::Error> for RecorderManagerError {
|
impl From<hyper::Error> for RecorderManagerError {
|
||||||
@@ -63,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 {
|
impl From<RecorderManagerError> for String {
|
||||||
fn from(value: RecorderManagerError) -> Self {
|
fn from(value: RecorderManagerError) -> Self {
|
||||||
value.to_string()
|
value.to_string()
|
||||||
@@ -70,12 +76,16 @@ impl From<RecorderManagerError> for String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RecorderManager {
|
impl RecorderManager {
|
||||||
|
pub fn new(
|
||||||
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
|
app_handle: AppHandle,
|
||||||
|
db: Arc<Database>,
|
||||||
|
config: Arc<RwLock<Config>>,
|
||||||
|
) -> RecorderManager {
|
||||||
RecorderManager {
|
RecorderManager {
|
||||||
app_handle,
|
app_handle,
|
||||||
|
db,
|
||||||
config,
|
config,
|
||||||
recorders: Arc::new(DashMap::new()),
|
recorders: Arc::new(RwLock::new(HashMap::new())),
|
||||||
hls_server_addr: Arc::new(RwLock::new(None)),
|
hls_server_addr: Arc::new(RwLock::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,122 +93,143 @@ impl RecorderManager {
|
|||||||
/// starting HLS server
|
/// starting HLS server
|
||||||
pub async fn run_hls(&self) -> Result<(), RecorderManagerError> {
|
pub async fn run_hls(&self) -> Result<(), RecorderManagerError> {
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
let listener = TcpListener::bind(&addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
let server_addr = self.start_hls_server(listener).await?;
|
let addr = self.start_hls_server(listener).await?;
|
||||||
log::info!("HLS server started on {}", server_addr);
|
*self.hls_server_addr.write().await = Some(addr);
|
||||||
self.hls_server_addr.write().await.replace(server_addr);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_recorder(
|
pub async fn add_recorder(
|
||||||
&self,
|
&self,
|
||||||
webid: &str,
|
webid: &str,
|
||||||
db: &Arc<Database>,
|
|
||||||
account: &AccountRow,
|
account: &AccountRow,
|
||||||
|
platform: PlatformType,
|
||||||
room_id: u64,
|
room_id: u64,
|
||||||
) -> Result<(), RecorderManagerError> {
|
) -> Result<(), RecorderManagerError> {
|
||||||
// check existing recorder
|
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||||
if self.recorders.contains_key(&room_id) {
|
if self.recorders.read().await.contains_key(&recorder_id) {
|
||||||
return Err(RecorderManagerError::AlreadyExisted { room_id });
|
return Err(RecorderManagerError::AlreadyExisted { room_id });
|
||||||
}
|
}
|
||||||
let recorder = BiliRecorder::new(
|
|
||||||
|
let recorder: Box<dyn Recorder + 'static> = match platform {
|
||||||
|
PlatformType::BiliBili => Box::new(
|
||||||
|
BiliRecorder::new(
|
||||||
self.app_handle.clone(),
|
self.app_handle.clone(),
|
||||||
webid,
|
webid,
|
||||||
db,
|
&self.db,
|
||||||
room_id,
|
room_id,
|
||||||
account,
|
account,
|
||||||
self.config.clone(),
|
self.config.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?,
|
||||||
self.recorders.insert(room_id, recorder);
|
),
|
||||||
// run recorder
|
PlatformType::Douyin => Box::new(DouyinRecorder::new(
|
||||||
let recorder = self.recorders.get(&room_id).unwrap();
|
room_id,
|
||||||
recorder.value().run().await;
|
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;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_recorder(&self, room_id: u64) -> Result<(), RecorderManagerError> {
|
pub async fn stop_all(&self) {
|
||||||
let recorder = self.recorders.remove(&room_id);
|
for recorder_ref in self.recorders.read().await.values() {
|
||||||
if recorder.is_none() {
|
recorder_ref.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove all recorders
|
||||||
|
self.recorders.write().await.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_recorder(
|
||||||
|
&self,
|
||||||
|
platform: PlatformType,
|
||||||
|
room_id: u64,
|
||||||
|
) -> 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 });
|
return Err(RecorderManagerError::NotFound { room_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// remove related cache folder
|
||||||
let cache_folder = format!(
|
let cache_folder = format!(
|
||||||
"{}/{}",
|
"{}/{}/{}",
|
||||||
self.config.read().await.cache,
|
self.config.read().await.cache,
|
||||||
room_id.to_string()
|
platform.as_str(),
|
||||||
|
room_id
|
||||||
);
|
);
|
||||||
tokio::fs::remove_dir_all(cache_folder).await?;
|
let _ = tokio::fs::remove_dir_all(cache_folder).await;
|
||||||
Ok(())
|
log::info!("Recorder {} cache folder removed", room_id);
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clip(
|
Ok(())
|
||||||
&self,
|
|
||||||
output_path: &str,
|
|
||||||
room_id: u64,
|
|
||||||
d: f64,
|
|
||||||
) -> Result<String, RecorderManagerError> {
|
|
||||||
let recorder = self.recorders.get(&room_id);
|
|
||||||
if recorder.is_none() {
|
|
||||||
return Err(RecorderManagerError::NotFound { room_id });
|
|
||||||
}
|
|
||||||
let recorder = recorder.unwrap();
|
|
||||||
Ok(recorder.value().clip(room_id, d, output_path).await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clip_range(
|
pub async fn clip_range(
|
||||||
&self,
|
&self,
|
||||||
output_path: &str,
|
output_path: &str,
|
||||||
|
platform: PlatformType,
|
||||||
room_id: u64,
|
room_id: u64,
|
||||||
ts: u64,
|
live_id: &str,
|
||||||
start: f64,
|
start: f64,
|
||||||
end: f64,
|
end: f64,
|
||||||
) -> Result<String, RecorderManagerError> {
|
) -> Result<String, RecorderManagerError> {
|
||||||
let recorder = self.recorders.get(&room_id);
|
let recorders = self.recorders.read().await;
|
||||||
if recorder.is_none() {
|
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 });
|
return Err(RecorderManagerError::NotFound { room_id });
|
||||||
}
|
}
|
||||||
let recorder = recorder.unwrap();
|
let recorder = recorders.get(&recorder_id).unwrap();
|
||||||
Ok(recorder
|
Ok(recorder
|
||||||
.value()
|
.clip_range(live_id, start, end, output_path)
|
||||||
.clip_range(ts, start, end, output_path)
|
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_recorder_list(&self) -> RecorderList {
|
pub async fn get_recorder_list(&self) -> RecorderList {
|
||||||
let mut summary = RecorderList {
|
let mut summary = RecorderList {
|
||||||
count: self.recorders.len(),
|
count: self.recorders.read().await.len(),
|
||||||
recorders: Vec::new(),
|
recorders: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for recorder in self.recorders.iter() {
|
for recorder_ref in self.recorders.read().await.iter() {
|
||||||
let recorder = recorder.value();
|
let room_info = recorder_ref.1.info().await;
|
||||||
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,
|
|
||||||
};
|
|
||||||
summary.recorders.push(room_info);
|
summary.recorders.push(room_info);
|
||||||
}
|
}
|
||||||
|
|
||||||
summary.recorders.sort_by(|a, b| a.room_id.cmp(&b.room_id));
|
summary.recorders.sort_by(|a, b| a.room_id.cmp(&b.room_id));
|
||||||
|
|
||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_recorder_info(&self, room_id: u64) -> Option<RecorderInfo> {
|
pub async fn get_recorder_info(
|
||||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
&self,
|
||||||
let room_info = RecorderInfo {
|
platform: PlatformType,
|
||||||
room_id: recorder.room_id,
|
room_id: u64,
|
||||||
room_info: recorder.room_info.read().await.clone(),
|
) -> Option<RecorderInfo> {
|
||||||
user_info: recorder.user_info.read().await.clone(),
|
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||||
total_length: *recorder.ts_length.read().await,
|
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||||
current_ts: *recorder.timestamp.read().await,
|
let room_info = recorder_ref.info().await;
|
||||||
live_status: *recorder.live_status.read().await,
|
|
||||||
};
|
|
||||||
Some(room_info)
|
Some(room_info)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -206,28 +237,48 @@ impl RecorderManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_archives(&self, room_id: u64) -> Result<Vec<RecordRow>, RecorderManagerError> {
|
pub async fn get_archives(&self, room_id: u64) -> Result<Vec<RecordRow>, RecorderManagerError> {
|
||||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
Ok(self.db.get_records(room_id).await?)
|
||||||
Ok(recorder.get_archives().await?)
|
|
||||||
} else {
|
|
||||||
Err(RecorderManagerError::NotFound { room_id })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_archive(
|
pub async fn get_archive(
|
||||||
&self,
|
&self,
|
||||||
room_id: u64,
|
room_id: u64,
|
||||||
live_id: u64,
|
live_id: &str,
|
||||||
) -> Result<RecordRow, RecorderManagerError> {
|
) -> Result<RecordRow, RecorderManagerError> {
|
||||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
Ok(self.db.get_record(room_id, live_id).await?)
|
||||||
Ok(recorder.get_archive(live_id).await?)
|
|
||||||
} else {
|
|
||||||
Err(RecorderManagerError::NotFound { room_id })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_archive(&self, room_id: u64, ts: u64) {
|
pub async fn delete_archive(
|
||||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
&self,
|
||||||
recorder.delete_archive(ts).await;
|
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: &str,
|
||||||
|
) -> Result<Vec<DanmuEntry>, RecorderManagerError> {
|
||||||
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,11 +296,26 @@ impl RecorderManager {
|
|||||||
let recorders = recorders.clone();
|
let recorders = recorders.clone();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
async move {
|
async move {
|
||||||
|
// handle cors preflight request
|
||||||
|
if req.method() == Method::OPTIONS {
|
||||||
|
return Ok::<_, Infallible>(
|
||||||
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
.header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let cache_path = config.read().await.cache.clone();
|
let cache_path = config.read().await.cache.clone();
|
||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
let path_segs: Vec<&str> = path.split('/').collect();
|
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>(
|
return Ok::<_, Infallible>(
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(400)
|
.status(400)
|
||||||
@@ -257,13 +323,18 @@ impl RecorderManager {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// parse recorder type
|
||||||
|
let platform = path_segs[1];
|
||||||
// parse room id
|
// parse room id
|
||||||
let room_id = path_segs[1].parse::<u64>().unwrap();
|
let room_id = path_segs[2].parse::<u64>().unwrap();
|
||||||
let timestamp = path_segs[2].parse::<u64>().unwrap();
|
// parse live id
|
||||||
// if path is /room_id/{timestamp}/playlist.m3u8
|
let live_id = path_segs[3];
|
||||||
if path_segs[3] == "playlist.m3u8" {
|
|
||||||
|
if path_segs[4] == "playlist.m3u8" {
|
||||||
// get recorder
|
// 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() {
|
if recorder.is_none() {
|
||||||
return Ok::<_, Infallible>(
|
return Ok::<_, Infallible>(
|
||||||
Response::builder()
|
Response::builder()
|
||||||
@@ -274,7 +345,7 @@ impl RecorderManager {
|
|||||||
}
|
}
|
||||||
let recorder = recorder.unwrap();
|
let recorder = recorder.unwrap();
|
||||||
// response with recorder generated m3u8, which contains ts entries that cached in local
|
// 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>(
|
Ok::<_, Infallible>(
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(200)
|
.status(200)
|
||||||
@@ -287,8 +358,10 @@ impl RecorderManager {
|
|||||||
} else {
|
} else {
|
||||||
// try to find requested ts file in recorder's cache
|
// try to find requested ts file in recorder's cache
|
||||||
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
|
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
|
||||||
let ts_file = format!("{}/{}", cache_path, path);
|
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() {
|
if recorder.is_none() {
|
||||||
return Ok::<_, Infallible>(
|
return Ok::<_, Infallible>(
|
||||||
Response::builder()
|
Response::builder()
|
||||||
|
|||||||
23
src-tauri/src/state.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"identifier": "cn.vjoi.bilishadowreplay",
|
"identifier": "cn.vjoi.bilishadowreplay",
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"sql": {
|
"sql": {
|
||||||
"preload": ["sqlite:data.db"]
|
"preload": ["sqlite:data_v2.db"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
@@ -8,12 +8,9 @@
|
|||||||
"title": "BiliBili ShadowReplay",
|
"title": "BiliBili ShadowReplay",
|
||||||
"width": 1300,
|
"width": 1300,
|
||||||
"height": 600,
|
"height": 600,
|
||||||
"transparent": true,
|
"transparent": false,
|
||||||
"decorations": false,
|
"decorations": true,
|
||||||
"theme": "Light",
|
"theme": "Light"
|
||||||
"windowEffects": {
|
|
||||||
"effects": ["tabbed", "mica"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Room from "./lib/Room.svelte";
|
import Room from "./page/Room.svelte";
|
||||||
import BSidebar from "./lib/BSidebar.svelte";
|
import BSidebar from "./lib/BSidebar.svelte";
|
||||||
import Summary from "./lib/Summary.svelte";
|
import Summary from "./page/Summary.svelte";
|
||||||
import Setting from "./lib/Setting.svelte";
|
import Setting from "./page/Setting.svelte";
|
||||||
import Account from "./lib/Account.svelte";
|
import Account from "./page/Account.svelte";
|
||||||
import TitleBar from "./lib/TitleBar.svelte";
|
import About from "./page/About.svelte";
|
||||||
import Messages from "./lib/Messages.svelte";
|
|
||||||
import About from "./lib/About.svelte";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
let active = "#总览";
|
let active = "#总览";
|
||||||
let room_count = 0;
|
|
||||||
let message_cnt = 0;
|
|
||||||
let use_titlebar = platform() == "windows";
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{#if use_titlebar}
|
|
||||||
<TitleBar />
|
|
||||||
{/if}
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<BSidebar bind:activeUrl={active} {room_count} {message_cnt} />
|
<BSidebar bind:activeUrl={active} />
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content bg-white dark:bg-[#2c2c2e]">
|
||||||
<!-- switch component by active -->
|
<!-- switch component by active -->
|
||||||
<div class="page" class:visible={active == "#总览"}>
|
<div class="page" class:visible={active == "#总览"}>
|
||||||
<Summary />
|
<Summary />
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full page" class:visible={active == "#直播间"}>
|
<div class="page" class:visible={active == "#直播间"}>
|
||||||
<Room bind:room_count />
|
<Room />
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full page" class:visible={active == "#消息"}>
|
<div class="page" class:visible={active == "#账号"}>
|
||||||
<Messages bind:message_cnt />
|
|
||||||
</div>
|
|
||||||
<div class="h-full page" class:visible={active == "#账号"}>
|
|
||||||
<Account />
|
<Account />
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="page" class:visible={active == "#自动化"}>
|
|
||||||
<div>自动化[开发中]</div>
|
|
||||||
</div> -->
|
|
||||||
<div class="page" class:visible={active == "#设置"}>
|
<div class="page" class:visible={active == "#设置"}>
|
||||||
<Setting />
|
<Setting />
|
||||||
</div>
|
</div>
|
||||||
@@ -64,21 +49,25 @@
|
|||||||
|
|
||||||
.visible {
|
.visible {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
max-height: fit-content !important;
|
height: 100% !important;
|
||||||
transform: translateX(0) !important;
|
transform: translateX(0) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
max-height: 0;
|
height: 0;
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition:
|
transition:
|
||||||
opacity 0.5s ease-in-out,
|
opacity 0.5s ease-in-out,
|
||||||
transform 0.3s ease-in-out;
|
transform 0.3s ease-in-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,33 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
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 Player from "./lib/Player.svelte";
|
||||||
import TitleBar from "./lib/TitleBar.svelte";
|
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import html2canvas from "html2canvas";
|
|
||||||
import type { AccountInfo, RecordItem } from "./lib/db";
|
import type { AccountInfo, RecordItem } from "./lib/db";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import {
|
||||||
import { ClapperboardPlaySolid, PlayOutline } from "flowbite-svelte-icons";
|
ChevronRight,
|
||||||
import type { Profile, VideoItem, Config } from "./lib/interface";
|
ChevronLeft,
|
||||||
import { onMount } from "svelte";
|
Play,
|
||||||
|
Pen,
|
||||||
let use_titlebar = platform() == "windows";
|
} 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 appWindow = getCurrentWebviewWindow();
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
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 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
|
// get profile in local storage with a default value
|
||||||
let profile: Profile = get_profile();
|
let profile: Profile = get_profile();
|
||||||
@@ -46,6 +38,10 @@
|
|||||||
return default_profile();
|
return default_profile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
|
||||||
|
}
|
||||||
|
|
||||||
function default_profile(): Profile {
|
function default_profile(): Profile {
|
||||||
return {
|
return {
|
||||||
videos: [],
|
videos: [],
|
||||||
@@ -94,49 +90,47 @@
|
|||||||
return canvas.toDataURL();
|
return canvas.toDataURL();
|
||||||
}
|
}
|
||||||
|
|
||||||
let cover_text = "";
|
|
||||||
let preview = false;
|
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 uid_selected = 0;
|
||||||
let video_selected = 0;
|
let video_selected = 0;
|
||||||
$: video_src = video ? convertFileSrc(config.output + "/" + video.name) : "";
|
|
||||||
|
|
||||||
let accounts = [];
|
let accounts = [];
|
||||||
let videos = [];
|
let videos = [];
|
||||||
|
|
||||||
let video = null;
|
let selected_video = null;
|
||||||
let cover = "";
|
|
||||||
|
|
||||||
invoke("get_accounts").then((account_info: AccountInfo) => {
|
invoke("get_accounts").then((account_info: AccountInfo) => {
|
||||||
accounts = account_info.accounts.map((a) => {
|
accounts = account_info.accounts.map((a) => {
|
||||||
return {
|
return {
|
||||||
value: a.uid,
|
value: a.uid,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
platform: a.platform,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
console.log(accounts);
|
accounts = accounts.filter((a) => a.platform === 'bilibili');
|
||||||
});
|
});
|
||||||
|
|
||||||
get_video_list();
|
get_video_list();
|
||||||
|
|
||||||
invoke("get_archive", { roomId: room_id, liveId: ts }).then(
|
invoke("get_archive", { roomId: room_id, liveId: live_id }).then(
|
||||||
(a: RecordItem) => {
|
(a: RecordItem) => {
|
||||||
console.log(a);
|
console.log(a);
|
||||||
archive = a;
|
archive = a;
|
||||||
appWindow.setTitle(`[${room_id}][${format_ts(ts)}]${archive.title}`);
|
appWindow.setTitle(`[${room_id}]${archive.title}`);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function update_title(str: string) {
|
function update_title(str: string) {
|
||||||
appWindow.setTitle(
|
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() {
|
async function get_video_list() {
|
||||||
videos = (
|
videos = (
|
||||||
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
|
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
|
||||||
@@ -144,19 +138,22 @@
|
|||||||
return {
|
return {
|
||||||
value: v.id,
|
value: v.id,
|
||||||
name: v.file,
|
name: v.file,
|
||||||
|
file: convertFileSrc(config.output + "/" + v.file),
|
||||||
cover: v.cover,
|
cover: v.cover,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
console.log(videos, video_selected);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function find_video(e) {
|
function find_video(e) {
|
||||||
|
if (!e.target) {
|
||||||
|
selected_video = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = parseInt(e.target.value);
|
const id = parseInt(e.target.value);
|
||||||
video = videos.find((v) => {
|
selected_video = videos.find((v) => {
|
||||||
return v.value == id;
|
return v.value == id;
|
||||||
});
|
});
|
||||||
cover = video.cover;
|
console.log("video selected", videos, selected_video, e, id);
|
||||||
console.log("video selected", videos, video, e, id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generate_clip() {
|
async function generate_clip() {
|
||||||
@@ -171,43 +168,43 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
let new_cover = generateCover();
|
let new_cover = generateCover();
|
||||||
update_title(`切片生成中`);
|
update_title(`切片生成中`);
|
||||||
|
try {
|
||||||
let new_video = (await invoke("clip_range", {
|
let new_video = (await invoke("clip_range", {
|
||||||
roomId: room_id,
|
roomId: room_id,
|
||||||
|
platform: platform,
|
||||||
cover: new_cover,
|
cover: new_cover,
|
||||||
ts: ts,
|
liveId: live_id,
|
||||||
x: start,
|
x: start,
|
||||||
y: end,
|
y: end,
|
||||||
})) as VideoItem;
|
})) as VideoItem;
|
||||||
update_title(`切片生成成功`);
|
update_title(`切片生成成功`);
|
||||||
console.log("video file generatd:", video);
|
console.log("video file generatd:", selected_video);
|
||||||
await get_video_list();
|
await get_video_list();
|
||||||
video_selected = new_video.id;
|
video_selected = new_video.id;
|
||||||
video = videos.find((v) => {
|
selected_video = videos.find((v) => {
|
||||||
return v.value == new_video.id;
|
return v.value == new_video.id;
|
||||||
});
|
});
|
||||||
cover = new_video.cover;
|
selected_video.cover = new_video.cover;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
alert("Err generating clip: " + e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function do_post() {
|
async function do_post() {
|
||||||
if (!video) {
|
if (!selected_video) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
update_title(`投稿上传中`);
|
update_title(`投稿上传中`);
|
||||||
loading = true;
|
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
|
// update profile in local storage
|
||||||
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
|
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
|
||||||
invoke("upload_procedure", {
|
invoke("upload_procedure", {
|
||||||
uid: uid_selected,
|
uid: uid_selected,
|
||||||
roomId: room_id,
|
roomId: room_id,
|
||||||
videoId: video_selected,
|
videoId: video_selected,
|
||||||
cover: rendered_cover,
|
cover: selected_video.cover,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -224,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function delete_video() {
|
async function delete_video() {
|
||||||
if (!video) {
|
if (!selected_video) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -233,148 +230,375 @@
|
|||||||
update_title(`删除成功`);
|
update_title(`删除成功`);
|
||||||
loading = false;
|
loading = false;
|
||||||
video_selected = 0;
|
video_selected = 0;
|
||||||
video = null;
|
selected_video = null;
|
||||||
cover = "";
|
|
||||||
await get_video_list();
|
await get_video_list();
|
||||||
}
|
}
|
||||||
|
let player;
|
||||||
// when window resize, update post panel height
|
let lpanel_collapsed = true;
|
||||||
onMount(() => {
|
let rpanel_collapsed = false;
|
||||||
let post_panel = document.getElementById("post-panel");
|
let markers: Marker[] = [];
|
||||||
if (post_panel) {
|
// load markers from local storage
|
||||||
post_panel.style.height = `calc(100vh - 35px)`;
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
if (post_panel) {
|
|
||||||
post_panel.style.height = `calc(100vh - 35px)`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{#if use_titlebar}
|
<div class="flex flex-row overflow-hidden">
|
||||||
<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
|
<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 class="relative flex w-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
id="post-panel"
|
class="w-[200px] transition-all duration-300 overflow-hidden flex-shrink-0"
|
||||||
class="mt-6 overflow-y-auto overflow-x-hidden p-6"
|
style="margin-left: {lpanel_collapsed ? '-200px' : '0'};"
|
||||||
class:titlebar={use_titlebar}
|
>
|
||||||
|
<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 -->
|
||||||
|
<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 -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
{#if video}
|
|
||||||
<div
|
<div
|
||||||
class="w-full mb-2"
|
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"
|
||||||
on:click={() => {
|
class:opacity-0={!preview}
|
||||||
preview = true;
|
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
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-800/50 bg-[#2c2c2e]">
|
||||||
class="cover-text absolute py-1 px-8"
|
<h3 class="text-lg font-medium text-white">预览视频</h3>
|
||||||
class:play-icon={false}
|
<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}
|
<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>
|
||||||
<div class="play-icon opacity-0">
|
<!-- 视频容器 -->
|
||||||
<PlayOutline class="w-full h-full absolute" color="white" />
|
<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>
|
||||||
<img src={cover} alt="cover" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="w-full flex flex-col justify-center">
|
</div>
|
||||||
<Label>切片列表</Label>
|
<div
|
||||||
<Select
|
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"
|
||||||
items={videos}
|
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}
|
bind:value={video_selected}
|
||||||
on:change={find_video}
|
on:change={find_video}
|
||||||
class="mb-2"
|
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||||
/>
|
border border-gray-800/50 focus:border-[#0A84FF]
|
||||||
<ButtonGroup>
|
transition duration-200 outline-none appearance-none
|
||||||
<Button on:click={generate_clip} disabled={loading} color="primary">
|
hover:border-gray-700/50"
|
||||||
{#if loading}
|
|
||||||
<Spinner class="me-3" size="4" />
|
|
||||||
{:else}
|
|
||||||
<ClapperboardPlaySolid />
|
|
||||||
{/if}
|
|
||||||
从选区生成新切片</Button
|
|
||||||
>
|
>
|
||||||
<Button
|
<option value={0}>选择切片</option>
|
||||||
color="red"
|
{#each videos as video}
|
||||||
disabled={!loading && !video}
|
<option value={video.value}>{video.name}</option>
|
||||||
on:click={delete_video}>删除</Button
|
{/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}
|
||||||
>
|
>
|
||||||
</ButtonGroup>
|
<Pen class="w-4 h-4" />
|
||||||
|
<span class="text-xs">创建新封面</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Hr />
|
<div
|
||||||
<Label class="mt-4">标题</Label>
|
id="capture"
|
||||||
<Input
|
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50 cursor-pointer group"
|
||||||
size="sm"
|
on:click={() => preview = true}
|
||||||
bind:value={profile.title}
|
>
|
||||||
on:change={() => {
|
<div
|
||||||
window.localStorage.setItem(
|
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100
|
||||||
"profile-" + room_id,
|
transition duration-200 flex items-center justify-center backdrop-blur-[2px]"
|
||||||
JSON.stringify(profile),
|
>
|
||||||
);
|
<div
|
||||||
}}
|
class="bg-white/10 backdrop-blur p-3 rounded-full opacity-0 group-hover:opacity-50"
|
||||||
/>
|
>
|
||||||
<Label class="mt-2">封面文本</Label>
|
<Play class="w-6 h-6 text-white" />
|
||||||
<Textarea bind:value={cover_text} />
|
</div>
|
||||||
<Label class="mt-2">描述</Label>
|
</div>
|
||||||
<Textarea
|
<img src={selected_video.cover} alt="视频封面" class="w-full" />
|
||||||
bind:value={profile.desc}
|
</div>
|
||||||
on:change={() => {
|
</div>
|
||||||
window.localStorage.setItem(
|
</section>
|
||||||
"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 loading}
|
|
||||||
<Spinner class="me-3" size="4" />
|
|
||||||
{/if}
|
{/if}
|
||||||
投稿
|
|
||||||
</Button>
|
<!-- 表单区域 -->
|
||||||
|
<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}
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span>投稿</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,31 +606,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<CoverEditor
|
||||||
|
bind:show={show_cover_editor}
|
||||||
|
video={selected_video}
|
||||||
|
on:coverUpdate={(event) => {
|
||||||
|
selected_video = {
|
||||||
|
...selected_video,
|
||||||
|
cover: event.detail.cover
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
main {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
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 {
|
.collapse-btn.rp {
|
||||||
opacity: 0.5;
|
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 {
|
.collapse-btn.lp {
|
||||||
white-space: pre-wrap;
|
right: -20px;
|
||||||
font-size: 24px;
|
border-radius: 0 4px 4px 0;
|
||||||
line-height: 1.3;
|
border: 2px solid rgb(31 41 55 / var(--tw-border-opacity));
|
||||||
font-weight: bold;
|
border-left: none;
|
||||||
color: rgb(255, 127, 0);
|
background-color: rgb(3 7 18 / var(--tw-bg-opacity));
|
||||||
text-shadow:
|
transform: translateY(-50%);
|
||||||
-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); /* 创建细腻的白色描边效果 */
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,41 +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 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>
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Table,
|
|
||||||
TableHead,
|
|
||||||
TableHeadCell,
|
|
||||||
TableBody,
|
|
||||||
TableBodyRow,
|
|
||||||
TableBodyCell,
|
|
||||||
Modal,
|
|
||||||
ButtonGroup,
|
|
||||||
} 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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<div class="fixed end-4 bottom-4">
|
|
||||||
<Button
|
|
||||||
pill={true}
|
|
||||||
class="!p-2"
|
|
||||||
on:click={() => {
|
|
||||||
addModal = true;
|
|
||||||
requestAnimationFrame(handle_qr);
|
|
||||||
}}><UserAddSolid class="w-8 h-8" /></Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="请使用 BiliBili App 扫码登录"
|
|
||||||
bind:open={addModal}
|
|
||||||
size="sm"
|
|
||||||
autoclose
|
|
||||||
>
|
|
||||||
<div class="flex justify-center items-center h-full">
|
|
||||||
<canvas id="qr" />
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
@@ -1,152 +1,68 @@
|
|||||||
<script>
|
<script>
|
||||||
import {
|
import { Info, LayoutDashboard, Settings, Users, Video, FileVideo } from "lucide-svelte";
|
||||||
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
|
// acitveUrl is shared between project
|
||||||
export let activeUrl = "#总览";
|
export let activeUrl = "#总览";
|
||||||
export let room_count = 0;
|
|
||||||
export let message_cnt = 0;
|
function navigate(route) {
|
||||||
|
activeUrl = route;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar {activeUrl} asideClass="w-72 h-full z-[40]">
|
<style>
|
||||||
<SidebarWrapper divClass="overflow-y-auto py-4 px-3 bg-gray-50 h-full">
|
:global(img.text-\[\#0A84FF\]) {
|
||||||
<SidebarGroup>
|
filter: invert(48%) sepia(85%) saturate(2229%) hue-rotate(198deg) brightness(100%) contrast(101%);
|
||||||
<SidebarItem
|
}
|
||||||
label="总览"
|
|
||||||
href="#"
|
:global(img.text-gray-700) {
|
||||||
on:click={() => {
|
filter: invert(23%) sepia(10%) saturate(532%) hue-rotate(182deg) brightness(94%) contrast(90%);
|
||||||
activeUrl = "#总览";
|
}
|
||||||
}}
|
|
||||||
|
: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]"
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="icon">
|
<LayoutDashboard
|
||||||
<ChartPieSolid
|
class="w-5 h-5 {activeUrl === '#总览' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}"
|
||||||
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>
|
<span>总览</span>
|
||||||
</SidebarItem>
|
</button>
|
||||||
<SidebarItem
|
<button
|
||||||
label="直播间"
|
on:click={() => navigate("#直播间")}
|
||||||
href="#"
|
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]"
|
||||||
on:click={() => {
|
|
||||||
activeUrl = "#直播间";
|
|
||||||
}}
|
|
||||||
{spanClass}
|
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="icon">
|
<Video class="w-5 h-5 {activeUrl === '#直播间' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
|
||||||
<GridSolid
|
<span>直播间</span>
|
||||||
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"
|
</button>
|
||||||
/>
|
<button
|
||||||
</svelte:fragment>
|
on:click={() => navigate("#账号")}
|
||||||
<svelte:fragment slot="subtext">
|
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]"
|
||||||
<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}
|
<Users class="w-5 h-5 {activeUrl === '#账号' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
|
||||||
</span>
|
<span>账号</span>
|
||||||
</svelte:fragment>
|
</button>
|
||||||
</SidebarItem>
|
<button
|
||||||
<SidebarItem
|
on:click={() => navigate("#设置")}
|
||||||
label="消息"
|
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]"
|
||||||
href="#"
|
|
||||||
on:click={() => {
|
|
||||||
activeUrl = "#消息";
|
|
||||||
}}
|
|
||||||
{spanClass}
|
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="icon">
|
<Settings class="w-5 h-5 {activeUrl === '#设置' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
|
||||||
<MailBoxSolid
|
<span>设置</span>
|
||||||
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"
|
</button>
|
||||||
/>
|
<button
|
||||||
</svelte:fragment>
|
on:click={() => navigate("#关于")}
|
||||||
<svelte:fragment slot="subtext">
|
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]"
|
||||||
<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}
|
<Info class="w-5 h-5 {activeUrl === '#关于' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
|
||||||
</span>
|
<span>关于</span>
|
||||||
</svelte:fragment>
|
</button>
|
||||||
</SidebarItem>
|
</nav>
|
||||||
<SidebarItem
|
</div>
|
||||||
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>
|
|
||||||
8
src/lib/BilibiliIcon.svelte
Normal 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
@@ -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>
|
||||||
8
src/lib/DouyinIcon.svelte
Normal 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>
|
||||||
@@ -5,13 +5,11 @@
|
|||||||
let b = "";
|
let b = "";
|
||||||
async function getImage(url: string) {
|
async function getImage(url: string) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return "";
|
return "/imgs/douyin.png";
|
||||||
}
|
}
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
});
|
});
|
||||||
console.log(response.status); // e.g. 200
|
|
||||||
console.log(response.statusText); // e.g. "OK"
|
|
||||||
return URL.createObjectURL(await response.blob());
|
return URL.createObjectURL(await response.blob());
|
||||||
}
|
}
|
||||||
async function init() {
|
async function init() {
|
||||||
|
|||||||
144
src/lib/MarkerPanel.svelte
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -1,16 +1,76 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
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";
|
||||||
|
|
||||||
export let port;
|
import { createEventDispatcher } from "svelte";
|
||||||
export let room_id;
|
import { GridOutline, SortHorizontalOutline } from "flowbite-svelte-icons";
|
||||||
export let ts;
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
interface DanmuEntry {
|
||||||
|
ts: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let port: number;
|
||||||
|
export let platform: string;
|
||||||
|
export let room_id: number;
|
||||||
|
export let live_id: string;
|
||||||
export let start = 0;
|
export let start = 0;
|
||||||
export let end = 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_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}/${platform}/${room_id}/${live_id}/playlist.m3u8`
|
||||||
|
)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((m3u8Content) => {
|
||||||
|
const offsetRegex = /#EXT-X-OFFSET:(\d+)/;
|
||||||
|
const match = m3u8Content.match(offsetRegex);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
global_offset = parseInt(match[1], 10);
|
||||||
|
} else {
|
||||||
|
console.warn("No #EXT-X-OFFSET found");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching M3U8 file:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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 ui = video["ui"];
|
||||||
const controls = ui.getControls();
|
const controls = ui.getControls();
|
||||||
const player = controls.getPlayer();
|
const player = controls.getPlayer();
|
||||||
@@ -26,9 +86,24 @@
|
|||||||
// Attach player and UI to the window to make it easy to access in the JS console.
|
// Attach player and UI to the window to make it easy to access in the JS console.
|
||||||
(window as any).player = player;
|
(window as any).player = player;
|
||||||
(window as any).ui = ui;
|
(window as any).ui = ui;
|
||||||
|
|
||||||
|
player.configure({
|
||||||
|
streaming: {
|
||||||
|
lowLatencyMode: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
player.addEventListener("ended", async () => {
|
||||||
|
// prevent endless reload
|
||||||
|
setTimeout(location.reload, 3 * 1000);
|
||||||
|
});
|
||||||
|
player.addEventListener("manifestloaded", (event) => {
|
||||||
|
console.log("Manifest loaded:", event);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await player.load(
|
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.
|
// This runs if the asynchronous load is successful.
|
||||||
console.log("The video has now been loaded!");
|
console.log("The video has now been loaded!");
|
||||||
@@ -39,8 +114,17 @@
|
|||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.addEventListener("ended", async () => {
|
|
||||||
location.reload();
|
// init video volume from localStorage
|
||||||
|
let localVolume = localStorage.getItem(`volume:${room_id}`);
|
||||||
|
if (localVolume != undefined) {
|
||||||
|
console.log("Load local volume", localVolume);
|
||||||
|
video.volume = parseFloat(localVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener("volumechange", (event) => {
|
||||||
|
localStorage.setItem(`volume:${room_id}`, video.volume.toString());
|
||||||
|
console.log("Update volume to", video.volume);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
|
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
|
||||||
@@ -66,6 +150,39 @@
|
|||||||
// add to shaka-spacer
|
// add to shaka-spacer
|
||||||
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
|
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
|
||||||
|
|
||||||
|
let danmu_enabled = true;
|
||||||
|
// get danmaku record
|
||||||
|
let danmu_records: DanmuEntry[] = (await invoke("get_danmu_record", {
|
||||||
|
roomId: room_id,
|
||||||
|
liveId: live_id,
|
||||||
|
platform: platform,
|
||||||
|
})) as DanmuEntry[];
|
||||||
|
|
||||||
|
console.log("danmu loaded:", danmu_records.length);
|
||||||
|
|
||||||
|
let ts = parseInt(live_id);
|
||||||
|
|
||||||
|
if (platform == "bilibili") {
|
||||||
|
// 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 + ts) * 1000);
|
||||||
|
|
||||||
|
let danmus = danmu_records.filter((v) => {
|
||||||
|
return v.ts >= cur - 1000 && v.ts < cur;
|
||||||
|
});
|
||||||
|
danmus.forEach((v) => danmu_handler(v.content));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
if (isLive()) {
|
if (isLive()) {
|
||||||
// add a account select
|
// add a account select
|
||||||
const accountSelect = document.createElement("select");
|
const accountSelect = document.createElement("select");
|
||||||
@@ -81,6 +198,9 @@
|
|||||||
// get accounts from tauri
|
// get accounts from tauri
|
||||||
const account_info = (await invoke("get_accounts")) as AccountInfo;
|
const account_info = (await invoke("get_accounts")) as AccountInfo;
|
||||||
account_info.accounts.forEach((account) => {
|
account_info.accounts.forEach((account) => {
|
||||||
|
if (account.platform !== "bilibili") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = account.uid.toString();
|
option.value = account.uid.toString();
|
||||||
option.text = account.name;
|
option.text = account.name;
|
||||||
@@ -90,7 +210,7 @@
|
|||||||
const danmakuInput = document.createElement("input");
|
const danmakuInput = document.createElement("input");
|
||||||
danmakuInput.type = "text";
|
danmakuInput.type = "text";
|
||||||
danmakuInput.placeholder = "回车发送弹幕";
|
danmakuInput.placeholder = "回车发送弹幕";
|
||||||
danmakuInput.style.width = "50%";
|
danmakuInput.style.width = "30%";
|
||||||
danmakuInput.style.height = "30px";
|
danmakuInput.style.height = "30px";
|
||||||
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||||
danmakuInput.style.color = "white";
|
danmakuInput.style.color = "white";
|
||||||
@@ -115,7 +235,27 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let danmu_enabled = true;
|
shakaSpacer.appendChild(accountSelect);
|
||||||
|
shakaSpacer.appendChild(danmakuInput);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// create a danmaku toggle button
|
// create a danmaku toggle button
|
||||||
const danmakuToggle = document.createElement("button");
|
const danmakuToggle = document.createElement("button");
|
||||||
danmakuToggle.innerText = "弹幕已开启";
|
danmakuToggle.innerText = "弹幕已开启";
|
||||||
@@ -154,17 +294,12 @@
|
|||||||
// Store the positions of the last few danmakus to avoid overlap
|
// Store the positions of the last few danmakus to avoid overlap
|
||||||
const danmakuPositions = [];
|
const danmakuPositions = [];
|
||||||
|
|
||||||
// listen to danmaku event
|
function danmu_handler(content: string) {
|
||||||
listen("danmu:" + room_id, (event: { payload: string }) => {
|
|
||||||
// if not enabled or playback is not keep up with live, ignore the danmaku
|
|
||||||
if (!danmu_enabled || get_total() - video.currentTime > 5) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const danmaku = document.createElement("p");
|
const danmaku = document.createElement("p");
|
||||||
danmaku.style.position = "absolute";
|
danmaku.style.position = "absolute";
|
||||||
|
|
||||||
// Calculate a random position for the danmaku
|
// Calculate a random position for the danmaku
|
||||||
let topPosition;
|
let topPosition = 0;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
do {
|
do {
|
||||||
topPosition = Math.random() * 30;
|
topPosition = Math.random() * 30;
|
||||||
@@ -191,7 +326,8 @@
|
|||||||
danmaku.style.margin = "0";
|
danmaku.style.margin = "0";
|
||||||
danmaku.style.padding = "0";
|
danmaku.style.padding = "0";
|
||||||
danmaku.style.zIndex = "500";
|
danmaku.style.zIndex = "500";
|
||||||
danmaku.innerText = event.payload;
|
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
|
||||||
|
danmaku.innerText = content;
|
||||||
overlay.appendChild(danmaku);
|
overlay.appendChild(danmaku);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
||||||
@@ -199,10 +335,8 @@
|
|||||||
danmaku.addEventListener("transitionend", () => {
|
danmaku.addEventListener("transitionend", () => {
|
||||||
overlay.removeChild(danmaku);
|
overlay.removeChild(danmaku);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
shakaSpacer.appendChild(accountSelect);
|
|
||||||
shakaSpacer.appendChild(danmakuInput);
|
|
||||||
shakaSpacer.appendChild(danmakuToggle);
|
shakaSpacer.appendChild(danmakuToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +368,55 @@
|
|||||||
|
|
||||||
shakaSpacer.appendChild(playbackRateSelect);
|
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
|
// shaka-spacer should be flex-direction: column
|
||||||
shakaSpacer.style.flexDirection = "column";
|
shakaSpacer.style.flexDirection = "column";
|
||||||
|
|
||||||
@@ -255,6 +438,15 @@
|
|||||||
}
|
}
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "[":
|
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));
|
start = parseFloat(video.currentTime.toFixed(2));
|
||||||
if (end < start) {
|
if (end < start) {
|
||||||
end = get_total();
|
end = get_total();
|
||||||
@@ -262,6 +454,15 @@
|
|||||||
console.log(start, end);
|
console.log(start, end);
|
||||||
break;
|
break;
|
||||||
case "]":
|
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));
|
end = parseFloat(video.currentTime.toFixed(2));
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
start = 0;
|
start = 0;
|
||||||
@@ -269,6 +470,7 @@
|
|||||||
console.log(start, end);
|
console.log(start, end);
|
||||||
break;
|
break;
|
||||||
case " ":
|
case " ":
|
||||||
|
e.preventDefault();
|
||||||
if (e.repeat) {
|
if (e.repeat) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -278,22 +480,31 @@
|
|||||||
video.pause();
|
video.pause();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "m":
|
case "p":
|
||||||
|
e.preventDefault();
|
||||||
if (e.repeat) {
|
if (e.repeat) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
video.muted = !video.muted;
|
// dispatch event
|
||||||
|
dispatch("markerAdd", {
|
||||||
|
offset: video.currentTime,
|
||||||
|
realtime: ts + video.currentTime,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
|
e.preventDefault();
|
||||||
video.currentTime -= 3;
|
video.currentTime -= 3;
|
||||||
break;
|
break;
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
|
e.preventDefault();
|
||||||
video.currentTime += 3;
|
video.currentTime += 3;
|
||||||
break;
|
break;
|
||||||
case "q":
|
case "q":
|
||||||
|
e.preventDefault();
|
||||||
video.currentTime = start;
|
video.currentTime = start;
|
||||||
break;
|
break;
|
||||||
case "e":
|
case "e":
|
||||||
|
e.preventDefault();
|
||||||
if (end == 0) {
|
if (end == 0) {
|
||||||
video.currentTime = get_total();
|
video.currentTime = get_total();
|
||||||
} else {
|
} else {
|
||||||
@@ -301,23 +512,101 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "c":
|
case "c":
|
||||||
|
e.preventDefault();
|
||||||
start = 0;
|
start = 0;
|
||||||
end = 0;
|
end = 0;
|
||||||
break;
|
break;
|
||||||
case "h":
|
case "h":
|
||||||
|
e.preventDefault();
|
||||||
show_detail = !show_detail;
|
show_detail = !show_detail;
|
||||||
break;
|
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() {
|
function updateSeekbar() {
|
||||||
const total = get_total();
|
const total = get_total();
|
||||||
const first_point = start / total;
|
const first_point = start / total;
|
||||||
const second_point = end / total;
|
const second_point = end / total;
|
||||||
// set background color for self-defined seekbar between first_point and second_point using linear-gradient
|
// 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) ${
|
seekbarContainer.style.background = `linear-gradient(to right, rgba(255, 255, 255, 0.4) ${
|
||||||
first_point * 100
|
first_point * 100
|
||||||
}%, rgb(0, 255, 0) ${first_point * 100}%, rgb(0, 255, 0) ${
|
}%, rgb(0, 255, 0) ${first_point * 100}%, rgb(0, 255, 0) ${
|
||||||
@@ -327,10 +616,42 @@
|
|||||||
}%, rgba(255, 255, 255, 0.4) ${
|
}%, rgba(255, 255, 255, 0.4) ${
|
||||||
first_point * 100
|
first_point * 100
|
||||||
}%, rgba(255, 255, 255, 0.2) ${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);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(updateSeekbar);
|
requestAnimationFrame(updateSeekbar);
|
||||||
}
|
}
|
||||||
|
|
||||||
// receive tauri emit
|
// receive tauri emit
|
||||||
document.addEventListener("shaka-ui-loaded", init);
|
document.addEventListener("shaka-ui-loaded", init);
|
||||||
|
|
||||||
@@ -361,13 +682,46 @@
|
|||||||
<p><kbd>]</kbd>设定选区结束</p>
|
<p><kbd>]</kbd>设定选区结束</p>
|
||||||
<p><kbd>q</kbd>跳转到选区开始</p>
|
<p><kbd>q</kbd>跳转到选区开始</p>
|
||||||
<p><kbd>e</kbd>跳转到选区结束</p>
|
<p><kbd>e</kbd>跳转到选区结束</p>
|
||||||
<p><kbd>Alt</kbd><kbd>←</kbd>前进</p>
|
<p><kbd>←</kbd>前进</p>
|
||||||
<p><kbd>Alt</kbd><kbd>→</kbd>后退</p>
|
<p><kbd>→</kbd>后退</p>
|
||||||
<p><kbd>c</kbd>清除选区</p>
|
<p><kbd>c</kbd>清除选区</p>
|
||||||
<p><kbd>m</kbd>静音</p>
|
<p><kbd>p</kbd>创建标记</p>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
<style>
|
||||||
video {
|
video {
|
||||||
@@ -387,7 +741,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#overlay {
|
#overlay {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -399,4 +753,45 @@
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
pointer-events: none;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Database from "@tauri-apps/plugin-sql";
|
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#"
|
// sql: r#"
|
||||||
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, length INTEGER, size INTEGER, created_at TEXT);
|
// 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 {
|
export interface RecorderItem {
|
||||||
|
platform: string;
|
||||||
room_id: number;
|
room_id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountItem {
|
export interface AccountItem {
|
||||||
|
platform: string;
|
||||||
uid: number;
|
uid: number;
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
@@ -33,79 +35,17 @@ export interface MessageItem {
|
|||||||
|
|
||||||
// from RecordRow
|
// from RecordRow
|
||||||
export interface RecordItem {
|
export interface RecordItem {
|
||||||
|
platform: string;
|
||||||
title: string;
|
title: string;
|
||||||
live_id: number;
|
live_id: string;
|
||||||
room_id: number;
|
room_id: number;
|
||||||
length: number;
|
length: number;
|
||||||
size: number;
|
size: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
cover: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountInfo {
|
export interface AccountInfo {
|
||||||
primary_uid: number;
|
primary_uid: number;
|
||||||
accounts: AccountItem[];
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface RoomInfo {
|
export interface RoomInfo {
|
||||||
live_status: number;
|
live_status: number;
|
||||||
room_cover_url: string;
|
room_cover: string;
|
||||||
room_id: number;
|
room_id: number;
|
||||||
room_keyframe_url: string;
|
room_keyframe_url: string;
|
||||||
room_title: string;
|
room_title: string;
|
||||||
@@ -11,15 +11,16 @@ export interface UserInfo {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string;
|
user_name: string;
|
||||||
user_sign: string;
|
user_sign: string;
|
||||||
user_avatar_url: string;
|
user_avatar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecorderInfo {
|
export interface RecorderInfo {
|
||||||
|
platform: string;
|
||||||
room_id: number;
|
room_id: number;
|
||||||
room_info: RoomInfo;
|
room_info: RoomInfo;
|
||||||
user_info: UserInfo;
|
user_info: UserInfo;
|
||||||
total_length: number;
|
total_length: number;
|
||||||
current_ts: number;
|
current_live_id: string;
|
||||||
live_status: boolean;
|
live_status: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export interface Config {
|
|||||||
live_end_notify: boolean;
|
live_end_notify: boolean;
|
||||||
clip_notify: boolean;
|
clip_notify: boolean;
|
||||||
post_notify: boolean;
|
post_notify: boolean;
|
||||||
|
auto_cleanup: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiskInfo {
|
export interface DiskInfo {
|
||||||
@@ -96,3 +98,44 @@ export interface DiskInfo {
|
|||||||
total: number;
|
total: number;
|
||||||
free: 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
@@ -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} />
|
||||||
366
src/page/Account.svelte
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<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", { 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}
|
||||||
652
src/page/Room.svelte
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
<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("请检查ID是否有效:" + 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
@@ -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
@@ -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>
|
||||||
@@ -7,5 +7,17 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-white {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-primary {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-danger {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ const mobile =
|
|||||||
process.env.TAURI_PLATFORM === "ios";
|
process.env.TAURI_PLATFORM === "ios";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
|
// @ts-ignore
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
svelte({
|
svelte({
|
||||||
preprocess: [
|
preprocess: [
|
||||||
|
|||||||
286
yarn.lock
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
"@esbuild/darwin-arm64@0.18.20":
|
"@esbuild/darwin-arm64@0.18.20":
|
||||||
version "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==
|
integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==
|
||||||
|
|
||||||
"@esbuild/darwin-x64@0.18.20":
|
"@esbuild/darwin-x64@0.18.20":
|
||||||
@@ -121,19 +121,19 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64@0.18.20":
|
"@esbuild/win32-x64@0.18.20":
|
||||||
version "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==
|
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
|
||||||
|
|
||||||
"@floating-ui/core@^1.6.0":
|
"@floating-ui/core@^1.6.0":
|
||||||
version "1.6.7"
|
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==
|
integrity sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/utils" "^0.2.7"
|
"@floating-ui/utils" "^0.2.7"
|
||||||
|
|
||||||
"@floating-ui/dom@^1.6.10":
|
"@floating-ui/dom@^1.6.10":
|
||||||
version "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==
|
integrity sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/core" "^1.6.0"
|
"@floating-ui/core" "^1.6.0"
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
|
|
||||||
"@floating-ui/utils@^0.2.7":
|
"@floating-ui/utils@^0.2.7":
|
||||||
version "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==
|
integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
|
||||||
|
|
||||||
"@isaacs/cliui@^8.0.2":
|
"@isaacs/cliui@^8.0.2":
|
||||||
@@ -224,12 +224,12 @@
|
|||||||
|
|
||||||
"@popperjs/core@^2.9.3":
|
"@popperjs/core@^2.9.3":
|
||||||
version "2.11.8"
|
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==
|
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
|
||||||
|
|
||||||
"@rollup/plugin-node-resolve@^15.2.3":
|
"@rollup/plugin-node-resolve@^15.2.3":
|
||||||
version "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==
|
integrity sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@rollup/pluginutils" "^5.0.1"
|
"@rollup/pluginutils" "^5.0.1"
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
|
|
||||||
"@rollup/pluginutils@^5.0.1":
|
"@rollup/pluginutils@^5.0.1":
|
||||||
version "5.1.0"
|
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==
|
integrity sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/estree" "^1.0.0"
|
"@types/estree" "^1.0.0"
|
||||||
@@ -268,135 +268,130 @@
|
|||||||
svelte-hmr "^0.15.3"
|
svelte-hmr "^0.15.3"
|
||||||
vitefu "^0.2.4"
|
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":
|
"@tauri-apps/api@^2.0.0":
|
||||||
version "2.0.2"
|
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==
|
integrity sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==
|
||||||
|
|
||||||
"@tauri-apps/api@^2.0.0-rc.4":
|
"@tauri-apps/api@^2.1.1":
|
||||||
version "2.0.0-rc.4"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-rc.4.tgz#2b4c3493d86382981787c52006c6c9e5bf16bc08"
|
resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.1.1.tgz"
|
||||||
integrity sha512-UNiIhhKG08j4ooss2oEEVexffmWkgkYlC2M3GcX3VPtNsqFgVNL8Mcw/4Y7rO9M9S+ffAMnLOF5ypzyuyb8tyg==
|
integrity sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==
|
||||||
|
|
||||||
"@tauri-apps/cli-darwin-arm64@2.0.2":
|
"@tauri-apps/cli-darwin-arm64@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.0.2.tgz#5a078381ce0e9e83a710fa5ae812c8709ca993cc"
|
resolved "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.1.0.tgz"
|
||||||
integrity sha512-B+/a8Q6wAqmB4A4HVeK0oQP5TdQGKW60ZLOI9O2ktH2HPr9ETr3XkwXPuJ2uAOuGEgtRZHBgFOIgG000vMnKlg==
|
integrity sha512-ESc6J6CE8hl1yKH2vJ+ALF+thq4Be+DM1mvmTyUCQObvezNCNhzfS6abIUd3ou4x5RGH51ouiANeT3wekU6dCw==
|
||||||
|
|
||||||
"@tauri-apps/cli-darwin-x64@2.0.2":
|
"@tauri-apps/cli-darwin-x64@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.0.2.tgz#87db71cc8dfe2196b33cf5ab26b99a4fa3fa01ac"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.1.0.tgz#08c5f446b65bc351a8e74c0c8019324ae6864351"
|
||||||
integrity sha512-kaurhn6XT4gAVCPAQSSHl/CHFxTS0ljc47N7iGTSlYJ03sCWPRZeNuVa/bn6rolz9MA2JfnRnFqB1pUL6jzp9Q==
|
integrity sha512-TasHS442DFs8cSH2eUQzuDBXUST4ECjCd0yyP+zZzvAruiB0Bg+c8A+I/EnqCvBQ2G2yvWLYG8q/LI7c87A5UA==
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm-gnueabihf@2.0.2":
|
"@tauri-apps/cli-linux-arm-gnueabihf@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.0.2.tgz#0ca2dc0b563028181bb0f005d3049c01a7dd904f"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.1.0.tgz#681d0967d0335b93ed8ab4b8bb5d820c72cc8abf"
|
||||||
integrity sha512-bVrofjlacMxmGMcqK18iBW05tsZXOd19/MnqruFFcHSVjvkGGIXHMtUbMXnZNXBPkHDsnfytNtkY9SZGfCFaBA==
|
integrity sha512-aP7ZBGNL4ny07Cbb6kKpUOSrmhcIK2KhjviTzYlh+pPhAptxnC78xQGD3zKQkTi2WliJLPmBYbOHWWQa57lQ9w==
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm64-gnu@2.0.2":
|
"@tauri-apps/cli-linux-arm64-gnu@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.0.2.tgz#10c0baa2255dc476707bb06619e6a9bd8874c639"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.1.0.tgz#922ddc50849ae9f2976f30fdb4b79708badb767b"
|
||||||
integrity sha512-7XCBn0TTBVQGnV42dXcbHPLg/9W8kJoVzuliIozvNGyRWxfXqDbQYzpI48HUQG3LgHMabcw8+pVZAfGhevLrCA==
|
integrity sha512-ZTdgD5gLeMCzndMT2f358EkoYkZ5T+Qy6zPzU+l5vv5M7dHVN9ZmblNAYYXmoOuw7y+BY4X/rZvHV9pcGrcanQ==
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm64-musl@2.0.2":
|
"@tauri-apps/cli-linux-arm64-musl@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.2.tgz#6636e383dae70eabf9f6dd22470ad2edb9776d87"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.1.0.tgz#063020d217faf90b226d93b5054546ae0db14c16"
|
||||||
integrity sha512-1xi2SreGVlpAL68MCsDUY63rdItUdPZreXIAcOVqvUehcJRYOa1XGSBhrV0YXRgZeh0AtKC19z6PRzcv4rosZA==
|
integrity sha512-NzwqjUCilhnhJzusz3d/0i0F1GFrwCQbkwR6yAHUxItESbsGYkZRJk0yMEWkg3PzFnyK4cWTlQJMEU52TjhEzA==
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-x64-gnu@2.0.2":
|
"@tauri-apps/cli-linux-x64-gnu@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.0.2.tgz#925e7714ad72eff67b42ed25a781b9f13262c296"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.1.0.tgz#7d7991a2b956232f96ec614f15189d408421dc03"
|
||||||
integrity sha512-WVjwYzPWFqZVg1fx6KSU5w47Q0VbMyaCp34qs5EcS8EIU0/RnofdzqUoOYqvgGVgNgoz7Pj5dXK2SkS8BHXMmA==
|
integrity sha512-TyiIpMEtZxNOQmuFyfJwaaYbg3movSthpBJLIdPlKxSAB2BW0VWLY3/ZfIxm/G2YGHyREkjJvimzYE0i37PnMA==
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-x64-musl@2.0.2":
|
"@tauri-apps/cli-linux-x64-musl@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.2.tgz#7e706660196c5db7ea821ceec7e315706f73cb5e"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.1.0.tgz#af6293ce56296619d656342f11d83c35c8465979"
|
||||||
integrity sha512-h5miE2mctgaQNn/BbG9o1pnJcrx+VGBi2A6JFqGu934lFgSV5+s28M8Gc8AF2JgFH4hQV4IuMkeSw8Chu5Dodg==
|
integrity sha512-/dQd0TlaxBdJACrR72DhynWftzHDaX32eBtS5WBrNJ+nnNb+znM3gON6nJ9tSE9jgDa6n1v2BkI/oIDtypfUXw==
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-arm64-msvc@2.0.2":
|
"@tauri-apps/cli-win32-arm64-msvc@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.0.2.tgz#aaee06d53f5d42afe8bf994e42cf5679c9e1ebd3"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.1.0.tgz#adb2b17d9939cdbcb136c5e24bf90d15485265dc"
|
||||||
integrity sha512-2b8oO0+dYonahG5PfA/zoq0zlafLclfmXgqoWDZ++UiPtQHJNpNeEQ8GWbSFKGHQ494Jo6jHvazOojGRE1kqAg==
|
integrity sha512-NdQJO7SmdYqOcE+JPU7bwg7+odfZMWO6g8xF9SXYCMdUzvM2Gv/AQfikNXz5yS7ralRhNFuW32i5dcHlxh4pDg==
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-ia32-msvc@2.0.2":
|
"@tauri-apps/cli-win32-ia32-msvc@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.0.2.tgz#ec6d8eef16d024eeae2ce4e3422b8a51f419e661"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.1.0.tgz#5356a56a30bfc50a0edde3e91c16ccd3fa837d71"
|
||||||
integrity sha512-axgICLunFi0To3EibdCBgbST5RocsSmtM4c04+CbcX8WQQosJ9ziWlCSrrOTRr+gJERAMSvEyVUS98f6bWMw9A==
|
integrity sha512-f5h8gKT/cB8s1ticFRUpNmHqkmaLutT62oFDB7N//2YTXnxst7EpMIn1w+QimxTvTk2gcx6EcW6bEk/y2hZGzg==
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-x64-msvc@2.0.2":
|
"@tauri-apps/cli-win32-x64-msvc@2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.0.2.tgz#bb5d011a0d44297f148c654c05b93a71cebacaa1"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.1.0.tgz#065f76de7a3aabe6d490b7f15cb09e7f8e911614"
|
||||||
integrity sha512-JR17cM6+DyExZRgpXr2/DdqvcFYi/EKvQt8dI5R1/uQoesWd8jeNnrU7c1FG1Zmw9+pTzDztsNqEKsrNq2sNIg==
|
integrity sha512-P/+LrdSSb5Xbho1LRP4haBjFHdyPdjWvGgeopL96OVtrFpYnfC+RctB45z2V2XxqFk3HweDDxk266btjttfjGw==
|
||||||
|
|
||||||
"@tauri-apps/cli@^2.0.2":
|
"@tauri-apps/cli@^2.1.0":
|
||||||
version "2.0.2"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-2.0.2.tgz#908629846f6edb1622a480f69a9469b3b9b7110e"
|
resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.1.0.tgz"
|
||||||
integrity sha512-R4ontHZvXORArERAHIidp5zRfZEshZczTiK+poslBv7AGKpQZoMw+E49zns7mOmP64i2Cq9Ci0pJvi4Rm8Okzw==
|
integrity sha512-K2VhcKqBhAeS5pNOVdnR/xQRU6jwpgmkSL2ejHXcl0m+kaTggT0WRDQnFtPq6NljA7aE03cvwsbCAoFG7vtkJw==
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@tauri-apps/cli-darwin-arm64" "2.0.2"
|
"@tauri-apps/cli-darwin-arm64" "2.1.0"
|
||||||
"@tauri-apps/cli-darwin-x64" "2.0.2"
|
"@tauri-apps/cli-darwin-x64" "2.1.0"
|
||||||
"@tauri-apps/cli-linux-arm-gnueabihf" "2.0.2"
|
"@tauri-apps/cli-linux-arm-gnueabihf" "2.1.0"
|
||||||
"@tauri-apps/cli-linux-arm64-gnu" "2.0.2"
|
"@tauri-apps/cli-linux-arm64-gnu" "2.1.0"
|
||||||
"@tauri-apps/cli-linux-arm64-musl" "2.0.2"
|
"@tauri-apps/cli-linux-arm64-musl" "2.1.0"
|
||||||
"@tauri-apps/cli-linux-x64-gnu" "2.0.2"
|
"@tauri-apps/cli-linux-x64-gnu" "2.1.0"
|
||||||
"@tauri-apps/cli-linux-x64-musl" "2.0.2"
|
"@tauri-apps/cli-linux-x64-musl" "2.1.0"
|
||||||
"@tauri-apps/cli-win32-arm64-msvc" "2.0.2"
|
"@tauri-apps/cli-win32-arm64-msvc" "2.1.0"
|
||||||
"@tauri-apps/cli-win32-ia32-msvc" "2.0.2"
|
"@tauri-apps/cli-win32-ia32-msvc" "2.1.0"
|
||||||
"@tauri-apps/cli-win32-x64-msvc" "2.0.2"
|
"@tauri-apps/cli-win32-x64-msvc" "2.1.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-dialog@^2.0.0-rc.1":
|
"@tauri-apps/plugin-dialog@~2":
|
||||||
version "2.0.0-rc.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0-rc.1.tgz#a3b0fd51a547e796ff93c58172fad48ad61a9970"
|
resolved "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.1.tgz"
|
||||||
integrity sha512-H28gh6BfZtjflHQ+HrmWwunDriBI3AQLAKnMs50GA6zeNUULqbQr7VXbAAKeJL/0CmWcecID4PKXVoSlaWRhEg==
|
integrity sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
"@tauri-apps/api" "^2.0.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-fs@^2.0.0-rc.2":
|
"@tauri-apps/plugin-fs@~2":
|
||||||
version "2.0.0-rc.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-fs/-/plugin-fs-2.0.0-rc.2.tgz#85362170983b12ad6a4808e51daff5ba4a8916fa"
|
resolved "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.0.2.tgz"
|
||||||
integrity sha512-TFjCfso3tN4b5s2EBjqP8N2gYrPh93Ds3VNKj8pCXv4wbvnItyfG0aHO0haUsedBOHQryDwv9vDAdPX6/T0a+g==
|
integrity sha512-4YZaX2j7ta81M5/DL8aN10kTnpUkEpkPo1FTYPT8Dd0ImHe3azM8i8MrtjrDGoyBYLPO3zFv7df/mSCYF8oA0Q==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
"@tauri-apps/api" "^2.0.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-http@^2.0.0-rc.2":
|
"@tauri-apps/plugin-http@~2":
|
||||||
version "2.0.0-rc.2"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-http/-/plugin-http-2.0.0-rc.2.tgz#385a3c0808db86b59ab0b183c85e4fd2de989cbd"
|
resolved "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.0.1.tgz"
|
||||||
integrity sha512-s/AbbMaQPgqnIOoObvWNAjJOV17gyf9G+U6gmvjLoFbt7D6jsujOUW6fn+Oe/+rzNSEeo1ZSVrUoMen5DgM+OA==
|
integrity sha512-j6IA3pVBybSCwPpsihpX4z8bs6PluuGtr06ahL/xy4D8HunNBTmRmadJrFOQi0gOAbaig4MkQ15nzNLBLy8R1A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
"@tauri-apps/api" "^2.0.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-notification@~2":
|
"@tauri-apps/plugin-notification@~2":
|
||||||
version "2.0.0"
|
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==
|
integrity sha512-6qEDYJS7mgXZWLXA0EFL+DVCJh8sJlzSoyw6B50pxhLPVFjc5Vr5DVzl5W3mUHaYhod5wsC984eQnlCCGqxYDA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.0.0"
|
"@tauri-apps/api" "^2.0.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-os@^2.0.0-rc":
|
"@tauri-apps/plugin-os@~2":
|
||||||
version "2.0.0-rc.1"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.0.0-rc.1.tgz#325e23e007df6d3247654eca2741e700519ab1cd"
|
resolved "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.0.0.tgz"
|
||||||
integrity sha512-PV8zlSTmYfiN2xzILUmlDSEycS7UYbH2yXk/ZqF+qQU6/s+OVQvmSth4EhllFjcpvPbtqELvpzfjw+2qEouchA==
|
integrity sha512-M7hG/nNyQYTJxVG/UhTKhp9mpXriwWzrs9mqDreB8mIgqA3ek5nHLdwRZJWhkKjZrnDT4v9CpA9BhYeplTlAiA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
"@tauri-apps/api" "^2.0.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-shell@^2.0.0-rc.1":
|
"@tauri-apps/plugin-shell@~2":
|
||||||
version "2.0.0-rc.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0-rc.1.tgz#9facf3bbcedfa2de676cb4cfc703687377aa12a3"
|
resolved "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.0.1.tgz"
|
||||||
integrity sha512-JtNROc0rqEwN/g93ig5pK4cl1vUo2yn+osCpY9de64cy/d9hRzof7AuYOgvt/Xcd5VPQmlgo2AGvUh5sQRSR1A==
|
integrity sha512-akU1b77sw3qHiynrK0s930y8zKmcdrSD60htjH+mFZqv5WaakZA/XxHR3/sF1nNv9Mgmt/Shls37HwnOr00aSw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
"@tauri-apps/api" "^2.0.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-sql@^2.0.0-rc.1":
|
"@tauri-apps/plugin-sql@~2":
|
||||||
version "2.0.0-rc.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-sql/-/plugin-sql-2.0.0-rc.1.tgz#aa21b4744cab72d082297844500c4b53837df964"
|
resolved "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.0.1.tgz"
|
||||||
integrity sha512-F8Wq+L8SdVt76WCU161WCeM5CLkh3gzKblXhXyFFJ8m+BY1P83zVyCxriems3mYULvPPtFwlg8NEgLJycS1HYQ==
|
integrity sha512-SxvRO/qwq/dHHGJ+79Bx4tB/wlfUE44sP1+wpuGOp11fgmfmOaf3nlZAl0P0KX+U3h0rwR/f7PMRQ6Eg408DYQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
"@tauri-apps/api" "^2.0.0"
|
||||||
|
|
||||||
"@tsconfig/node10@^1.0.7":
|
"@tsconfig/node10@^1.0.7":
|
||||||
version "1.0.11"
|
version "1.0.11"
|
||||||
@@ -425,17 +420,10 @@
|
|||||||
|
|
||||||
"@types/estree@^1.0.0":
|
"@types/estree@^1.0.0":
|
||||||
version "1.0.5"
|
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==
|
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
|
||||||
|
|
||||||
"@types/node@*":
|
"@types/node@*", "@types/node@^18.7.10":
|
||||||
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":
|
|
||||||
version "18.19.47"
|
version "18.19.47"
|
||||||
resolved "https://registry.npmjs.org/@types/node/-/node-18.19.47.tgz"
|
resolved "https://registry.npmjs.org/@types/node/-/node-18.19.47.tgz"
|
||||||
integrity sha512-1f7dB3BL/bpd9tnDJrrHb66Y+cVrhxSOTGorRNdHwYTUlTay3HuTDPKo9a/4vX9pMQkhYBcAbL4jQdNlhCFP9A==
|
integrity sha512-1f7dB3BL/bpd9tnDJrrHb66Y+cVrhxSOTGorRNdHwYTUlTay3HuTDPKo9a/4vX9pMQkhYBcAbL4jQdNlhCFP9A==
|
||||||
@@ -449,19 +437,19 @@
|
|||||||
|
|
||||||
"@types/qrcode@^1.5.5":
|
"@types/qrcode@^1.5.5":
|
||||||
version "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==
|
integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/resolve@1.20.2":
|
"@types/resolve@1.20.2":
|
||||||
version "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==
|
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
|
||||||
|
|
||||||
"@yr/monotone-cubic-spline@^1.0.3":
|
"@yr/monotone-cubic-spline@^1.0.3":
|
||||||
version "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==
|
integrity sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==
|
||||||
|
|
||||||
acorn-walk@^8.1.1:
|
acorn-walk@^8.1.1:
|
||||||
@@ -513,7 +501,7 @@ anymatch@~3.1.2:
|
|||||||
|
|
||||||
apexcharts@^3.53.0:
|
apexcharts@^3.53.0:
|
||||||
version "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==
|
integrity sha512-QESZHZY3w9LPQ64PGh1gEdfjYjJ5Jp+Dfy0D/CLjsLOPTpXzdxwlNMqRj+vPbTcP0nAHgjWv1maDqcEq6u5olw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@yr/monotone-cubic-spline" "^1.0.3"
|
"@yr/monotone-cubic-spline" "^1.0.3"
|
||||||
@@ -553,7 +541,7 @@ balanced-match@^1.0.0:
|
|||||||
|
|
||||||
base64-arraybuffer@^1.0.2:
|
base64-arraybuffer@^1.0.2:
|
||||||
version "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==
|
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
|
||||||
|
|
||||||
binary-extensions@^2.0.0:
|
binary-extensions@^2.0.0:
|
||||||
@@ -600,7 +588,7 @@ buffer-crc32@^1.0.0:
|
|||||||
|
|
||||||
builtin-modules@^3.3.0:
|
builtin-modules@^3.3.0:
|
||||||
version "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==
|
integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
|
||||||
|
|
||||||
camelcase-css@^2.0.1:
|
camelcase-css@^2.0.1:
|
||||||
@@ -614,9 +602,9 @@ camelcase@^5.0.0:
|
|||||||
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
|
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
|
||||||
|
|
||||||
caniuse-lite@^1.0.30001646:
|
caniuse-lite@^1.0.30001646:
|
||||||
version "1.0.30001653"
|
version "1.0.30001703"
|
||||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001653.tgz"
|
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz"
|
||||||
integrity sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==
|
integrity sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==
|
||||||
|
|
||||||
chokidar@^3.4.1, chokidar@^3.5.3:
|
chokidar@^3.4.1, chokidar@^3.5.3:
|
||||||
version "3.6.0"
|
version "3.6.0"
|
||||||
@@ -680,7 +668,7 @@ cross-spawn@^7.0.0:
|
|||||||
|
|
||||||
css-line-break@^2.1.0:
|
css-line-break@^2.1.0:
|
||||||
version "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==
|
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
|
||||||
dependencies:
|
dependencies:
|
||||||
utrie "^1.0.2"
|
utrie "^1.0.2"
|
||||||
@@ -792,7 +780,7 @@ escalade@^3.1.2:
|
|||||||
|
|
||||||
estree-walker@^2.0.2:
|
estree-walker@^2.0.2:
|
||||||
version "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==
|
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
|
||||||
|
|
||||||
fast-glob@^3.3.0:
|
fast-glob@^3.3.0:
|
||||||
@@ -830,7 +818,7 @@ find-up@^4.1.0:
|
|||||||
|
|
||||||
flowbite-datepicker@^1.3.0:
|
flowbite-datepicker@^1.3.0:
|
||||||
version "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==
|
integrity sha512-CLVqzuoE2vkUvWYK/lJ6GzT0be5dlTbH3uuhVwyB67+PjqJWABm2wv68xhBf5BqjpBxvTSQ3mrmLHpPJ2tvrSQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@rollup/plugin-node-resolve" "^15.2.3"
|
"@rollup/plugin-node-resolve" "^15.2.3"
|
||||||
@@ -838,12 +826,12 @@ flowbite-datepicker@^1.3.0:
|
|||||||
|
|
||||||
flowbite-svelte-icons@^1.6.1:
|
flowbite-svelte-icons@^1.6.1:
|
||||||
version "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==
|
integrity sha512-Kw/7BzA6fqlFq7tBNudwX0KVU4cbyyXcMcgHTraMwGBtvBQan0RKMbvWwqm4JZNvLGAvRv1BM2EF7rzo/oam1Q==
|
||||||
|
|
||||||
flowbite-svelte@^0.46.16:
|
flowbite-svelte@^0.46.16:
|
||||||
version "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==
|
integrity sha512-NkyMS/d1EwuL1cqstSUflnG9vhhBiNyUiAw51D8lfPKDfUG1iXc4+HueQw01zhHv3uSXRJRToFBrg6npxeJ3jw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/dom" "^1.6.10"
|
"@floating-ui/dom" "^1.6.10"
|
||||||
@@ -853,7 +841,7 @@ flowbite-svelte@^0.46.16:
|
|||||||
|
|
||||||
flowbite@^2.0.0, flowbite@^2.5.1:
|
flowbite@^2.0.0, flowbite@^2.5.1:
|
||||||
version "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==
|
integrity sha512-7jP1jy9c3QP7y+KU9lc8ueMkTyUdMDvRP+lteSWgY5TigSZjf9K1kqZxmqjhbx2gBnFQxMl1GAjVThCa8cEpKA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@popperjs/core" "^2.9.3"
|
"@popperjs/core" "^2.9.3"
|
||||||
@@ -880,7 +868,7 @@ fs.realpath@^1.0.0:
|
|||||||
|
|
||||||
fsevents@~2.3.2:
|
fsevents@~2.3.2:
|
||||||
version "2.3.3"
|
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==
|
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||||
|
|
||||||
function-bind@^1.1.2:
|
function-bind@^1.1.2:
|
||||||
@@ -945,7 +933,7 @@ hasown@^2.0.2:
|
|||||||
|
|
||||||
html2canvas@^1.4.1:
|
html2canvas@^1.4.1:
|
||||||
version "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==
|
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
|
||||||
dependencies:
|
dependencies:
|
||||||
css-line-break "^2.1.0"
|
css-line-break "^2.1.0"
|
||||||
@@ -973,7 +961,7 @@ is-binary-path@~2.1.0:
|
|||||||
|
|
||||||
is-builtin-module@^3.2.1:
|
is-builtin-module@^3.2.1:
|
||||||
version "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==
|
integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==
|
||||||
dependencies:
|
dependencies:
|
||||||
builtin-modules "^3.3.0"
|
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:
|
is-module@^1.0.0:
|
||||||
version "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==
|
integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
|
||||||
|
|
||||||
is-number@^7.0.0:
|
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"
|
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
|
||||||
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
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:
|
magic-string@^0.30.3, magic-string@^0.30.5:
|
||||||
version "0.30.11"
|
version "0.30.11"
|
||||||
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz"
|
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:
|
mini-svg-data-uri@^1.4.3:
|
||||||
version "1.4.4"
|
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==
|
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
|
||||||
|
|
||||||
minimatch@^3.1.1:
|
minimatch@^3.1.1:
|
||||||
@@ -1273,7 +1266,7 @@ postcss-import@^15.1.0:
|
|||||||
|
|
||||||
postcss-js@^4.0.1:
|
postcss-js@^4.0.1:
|
||||||
version "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==
|
integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==
|
||||||
dependencies:
|
dependencies:
|
||||||
camelcase-css "^2.0.1"
|
camelcase-css "^2.0.1"
|
||||||
@@ -1550,40 +1543,40 @@ svelte@^3.54.0:
|
|||||||
|
|
||||||
svg.draggable.js@^2.2.2:
|
svg.draggable.js@^2.2.2:
|
||||||
version "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==
|
integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==
|
||||||
dependencies:
|
dependencies:
|
||||||
svg.js "^2.0.1"
|
svg.js "^2.0.1"
|
||||||
|
|
||||||
svg.easing.js@^2.0.0:
|
svg.easing.js@^2.0.0:
|
||||||
version "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==
|
integrity sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==
|
||||||
dependencies:
|
dependencies:
|
||||||
svg.js ">=2.3.x"
|
svg.js ">=2.3.x"
|
||||||
|
|
||||||
svg.filter.js@^2.0.2:
|
svg.filter.js@^2.0.2:
|
||||||
version "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==
|
integrity sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==
|
||||||
dependencies:
|
dependencies:
|
||||||
svg.js "^2.2.5"
|
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:
|
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"
|
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==
|
integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==
|
||||||
|
|
||||||
svg.pathmorphing.js@^0.1.3:
|
svg.pathmorphing.js@^0.1.3:
|
||||||
version "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==
|
integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==
|
||||||
dependencies:
|
dependencies:
|
||||||
svg.js "^2.4.0"
|
svg.js "^2.4.0"
|
||||||
|
|
||||||
svg.resize.js@^1.4.3:
|
svg.resize.js@^1.4.3:
|
||||||
version "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==
|
integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==
|
||||||
dependencies:
|
dependencies:
|
||||||
svg.js "^2.6.5"
|
svg.js "^2.6.5"
|
||||||
@@ -1591,21 +1584,21 @@ svg.resize.js@^1.4.3:
|
|||||||
|
|
||||||
svg.select.js@^2.1.2:
|
svg.select.js@^2.1.2:
|
||||||
version "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==
|
integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
svg.js "^2.2.5"
|
svg.js "^2.2.5"
|
||||||
|
|
||||||
svg.select.js@^3.0.1:
|
svg.select.js@^3.0.1:
|
||||||
version "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==
|
integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==
|
||||||
dependencies:
|
dependencies:
|
||||||
svg.js "^2.6.5"
|
svg.js "^2.6.5"
|
||||||
|
|
||||||
tailwind-merge@^2.5.2:
|
tailwind-merge@^2.5.2:
|
||||||
version "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==
|
integrity sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==
|
||||||
|
|
||||||
tailwindcss@^3.3.0:
|
tailwindcss@^3.3.0:
|
||||||
@@ -1638,7 +1631,7 @@ tailwindcss@^3.3.0:
|
|||||||
|
|
||||||
text-segmentation@^1.0.3:
|
text-segmentation@^1.0.3:
|
||||||
version "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==
|
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
|
||||||
dependencies:
|
dependencies:
|
||||||
utrie "^1.0.2"
|
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"
|
resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz"
|
||||||
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
|
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:
|
update-browserslist-db@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz"
|
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:
|
utrie@^1.0.2:
|
||||||
version "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==
|
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-arraybuffer "^1.0.2"
|
base64-arraybuffer "^1.0.2"
|
||||||
|
|||||||