Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
fcccdee105 | ||
|
|
887072f6c7 | ||
|
|
1932edba21 | ||
|
|
0c15415822 | ||
|
|
b8dc0870b5 | ||
|
|
9d0ad2ae45 | ||
|
|
7278b9f48c | ||
|
|
1aee95492a | ||
|
|
0cff889f4b | ||
|
|
9cd05362ac | ||
|
|
269eccc7ef | ||
|
|
aafd02090b | ||
|
|
e0e43dbfa4 | ||
|
|
37c358a48b |
72
README.md
@@ -1,12 +1,64 @@
|
||||
# Bilibili ShadowReplay
|
||||
# BiliBili ShadowReplay
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
|
||||
|
||||
> [!NOTE]
|
||||
> 由于软件在快速开发中,截图说明可能有变动,仅供参考
|
||||
|
||||

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

|
||||
|
||||
显示直播缓存的占用以及缓存所在磁盘的使用情况。
|
||||
|
||||
## 直播间管理
|
||||
|
||||

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

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

|
||||
|
||||
执行的各种操作都会留下消息记录,方便查看过去进行的操作。
|
||||
|
||||
## 账号管理
|
||||
|
||||

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

|
||||
|
||||
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片、记录时间点以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
|
||||
|
||||
通过预览窗口的快捷键操作,可以快速选择时间区间,进行切片生成以及投稿。
|
||||
|
||||
无论是弹幕发送还是投稿,均可自由选择账号,只要在账号管理中添加了该账号。
|
||||
|
||||
## 设置
|
||||
|
||||

|
||||
|
||||
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
|
||||
|
||||
> [!WARNING]
|
||||
> 程序仍在开发中, Rlease 中提供的下载版本为历史遗留版本, 不保证能够正常使用
|
||||
|
||||

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

|
||||
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。缓存切换开始和结束均会在消息管理中有记录。
|
||||
|
||||
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: 487 KiB |
BIN
doc/archives.png
Normal file
|
After Width: | Height: | Size: 574 KiB |
BIN
doc/clip.png
|
Before Width: | Height: | Size: 3.0 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: 3.4 MiB |
BIN
doc/main.png
|
Before Width: | Height: | Size: 127 KiB |
BIN
doc/messages.png
Normal file
|
After Width: | Height: | Size: 638 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: 678 KiB |
BIN
doc/setting.png
|
Before Width: | Height: | Size: 136 KiB |
BIN
doc/settings.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
doc/summary.png
Normal file
|
After Width: | Height: | Size: 547 KiB |
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-cn">
|
||||
<html lang="zh-cn" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
@@ -18,6 +18,38 @@
|
||||
height: 12px; /* 设置滑块按钮高度 */
|
||||
border-radius: 50%; /* 设置为圆形 */
|
||||
}
|
||||
html {
|
||||
scrollbar-face-color: #646464;
|
||||
scrollbar-base-color: #646464;
|
||||
scrollbar-3dlight-color: #646464;
|
||||
scrollbar-highlight-color: #646464;
|
||||
scrollbar-track-color: #000;
|
||||
scrollbar-arrow-color: #000;
|
||||
scrollbar-shadow-color: #646464;
|
||||
scrollbar-dark-shadow-color: #646464;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-button {
|
||||
background-color: #666;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #646464;
|
||||
}
|
||||
::-webkit-scrollbar-track-piece {
|
||||
background-color: #000;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
height: 50px;
|
||||
background-color: #666;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: #646464;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bili-shadowreplay",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
310
src-tauri/Cargo.lock
generated
@@ -420,6 +420,7 @@ dependencies = [
|
||||
"notify-rust",
|
||||
"pct-str",
|
||||
"platform-dirs",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.11.15",
|
||||
"serde",
|
||||
@@ -1195,6 +1196,17 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.2"
|
||||
@@ -1440,11 +1452,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-sidecar"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bec830aba6cd3da7621c58e8f06727e3b750e8383bcd934d789f2b9c3c4ea595"
|
||||
checksum = "d67d09bdb90406a420b30ba06d464a976c9642081c2ecdf09e35ec80bd7eb9b1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest 0.12.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2293,6 +2306,22 @@ dependencies = [
|
||||
"tokio-native-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.4.1",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.8"
|
||||
@@ -2347,6 +2376,124 @@ dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid_transform"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locid",
|
||||
"icu_locid_transform_data",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid_transform_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locid_transform",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locid",
|
||||
"icu_provider_macros",
|
||||
"stable_deref_trait",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_macros"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -2373,6 +2520,27 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.2"
|
||||
@@ -2678,6 +2846,12 @@ version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
@@ -4019,7 +4193,7 @@ dependencies = [
|
||||
"http 0.2.9",
|
||||
"http-body 0.4.5",
|
||||
"hyper 0.14.25",
|
||||
"hyper-tls",
|
||||
"hyper-tls 0.5.0",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -4043,15 +4217,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.7"
|
||||
version = "0.12.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63"
|
||||
checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.6",
|
||||
@@ -4060,11 +4235,13 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper 1.4.1",
|
||||
"hyper-rustls",
|
||||
"hyper-tls 0.6.0",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -4078,6 +4255,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
@@ -5025,6 +5203,17 @@ dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.1"
|
||||
@@ -5161,7 +5350,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.12.7",
|
||||
"reqwest 0.12.8",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -5311,7 +5500,7 @@ dependencies = [
|
||||
"data-url",
|
||||
"http 1.1.0",
|
||||
"regex",
|
||||
"reqwest 0.12.7",
|
||||
"reqwest 0.12.8",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5621,6 +5810,16 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.6.0"
|
||||
@@ -6017,12 +6216,12 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.2"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
|
||||
checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna 0.5.0",
|
||||
"idna 1.0.3",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
@@ -6051,12 +6250,24 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-decode"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca61eb27fa339aa08826a29f03e87b99b4d8f0fc2255306fd266bb1b6a9de498"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.3.0"
|
||||
@@ -6911,6 +7122,18 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.44.1"
|
||||
@@ -6982,6 +7205,30 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "4.0.1"
|
||||
@@ -7067,12 +7314,55 @@ dependencies = [
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.0.0"
|
||||
|
||||
@@ -22,7 +22,7 @@ sysinfo = "0.32.0"
|
||||
m3u8-rs = "5.0.3"
|
||||
async-std = "1.12.0"
|
||||
futures = "0.3.28"
|
||||
ffmpeg-sidecar = "1.1"
|
||||
ffmpeg-sidecar = "1.2.0"
|
||||
chrono = { version = "0.4.24", features = ["serde"] }
|
||||
toml = "0.7.3"
|
||||
custom_error = "1.9.2"
|
||||
@@ -47,6 +47,7 @@ tauri-utils = "2.0.1"
|
||||
tauri-plugin-sql = { version = "2.0.1", features = ["sqlite"] }
|
||||
tauri-plugin-os = "2.0.1"
|
||||
tauri-plugin-notification = "2"
|
||||
rand = "0.8.5"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
|
||||
@@ -5004,6 +5004,171 @@
|
||||
"type": "string",
|
||||
"const": "http:deny-fetch-send"
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n",
|
||||
"type": "string",
|
||||
"const": "notification:default"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch"
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions"
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel"
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending"
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted"
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels"
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify"
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state"
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types"
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener"
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active"
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission"
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show"
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch"
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel"
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions"
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel"
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel"
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active"
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending"
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted"
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels"
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify"
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state"
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types"
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener"
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active"
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission"
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show"
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n",
|
||||
"type": "string",
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
111
src-tauri/src/database/account.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use super::Database;
|
||||
use super::DatabaseError;
|
||||
use chrono::Utc;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
// 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?,
|
||||
)
|
||||
}
|
||||
}
|
||||
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?)
|
||||
}
|
||||
}
|
||||
87
src-tauri/src/database/record.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use super::Database;
|
||||
use super::DatabaseError;
|
||||
use chrono::Utc;
|
||||
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
47
src-tauri/src/database/recorder.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use super::Database;
|
||||
use super::DatabaseError;
|
||||
use chrono::Utc;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
// 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?)
|
||||
}
|
||||
}
|
||||
90
src-tauri/src/database/video.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,34 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod db;
|
||||
mod database;
|
||||
mod recorder;
|
||||
mod recorder_manager;
|
||||
mod tray;
|
||||
|
||||
use chrono::Utc;
|
||||
use custom_error::custom_error;
|
||||
use db::{AccountRow, Database, MessageRow, RecordRow, VideoRow};
|
||||
use database::account::AccountRow;
|
||||
use database::message::MessageRow;
|
||||
use database::record::RecordRow;
|
||||
use database::recorder::RecorderRow;
|
||||
use database::video::VideoRow;
|
||||
use database::Database;
|
||||
use recorder::bilibili::errors::BiliClientError;
|
||||
use recorder::bilibili::profile::Profile;
|
||||
use recorder::bilibili::{BiliClient, QrInfo, QrStatus};
|
||||
use recorder::danmu::DanmuEntry;
|
||||
use recorder_manager::{RecorderInfo, RecorderList, RecorderManager};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tauri::utils::config::WindowEffectsConfig;
|
||||
use tauri::{Manager, Theme, WindowEvent};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use platform_dirs::AppDirs;
|
||||
@@ -308,10 +318,7 @@ async fn set_primary(state: tauri::State<'_, State>, uid: u64) -> Result<(), Str
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_recorder(
|
||||
state: tauri::State<'_, State>,
|
||||
room_id: u64,
|
||||
) -> Result<db::RecorderRow, String> {
|
||||
async fn add_recorder(state: tauri::State<'_, State>, room_id: u64) -> Result<RecorderRow, String> {
|
||||
let account = state
|
||||
.db
|
||||
.get_account(state.config.read().await.primary_uid)
|
||||
@@ -346,7 +353,13 @@ async fn add_recorder(
|
||||
#[tauri::command]
|
||||
async fn remove_recorder(state: tauri::State<'_, State>, room_id: u64) -> Result<(), String> {
|
||||
match state.recorder_manager.remove_recorder(room_id).await {
|
||||
Ok(()) => Ok(state.db.remove_recorder(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()),
|
||||
}
|
||||
}
|
||||
@@ -366,7 +379,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
// Copy old cache to new cache
|
||||
log::info!("Start copy old cache to new cache");
|
||||
state.db.new_message("缓存目录切换", "缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用").await?;
|
||||
state
|
||||
.db
|
||||
.new_message(
|
||||
"缓存目录切换",
|
||||
"缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用",
|
||||
)
|
||||
.await?;
|
||||
if let Err(e) = copy_dir_all(&old_cache_path, &cache_path) {
|
||||
log::error!("Copy old cache to new cache error: {}", e);
|
||||
}
|
||||
@@ -382,7 +401,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_notify(state: tauri::State<'_, State>, live_start_notify: bool, live_end_notify: bool, clip_notify: bool, post_notify: bool) -> Result<(), ()> {
|
||||
async fn update_notify(
|
||||
state: tauri::State<'_, 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;
|
||||
@@ -445,19 +470,21 @@ async fn clip_range(
|
||||
// add video to db
|
||||
let video = state
|
||||
.db
|
||||
.add_video(
|
||||
.add_video(&VideoRow {
|
||||
id: 0,
|
||||
status: 0,
|
||||
room_id,
|
||||
&cover,
|
||||
filename,
|
||||
(y - x) as i64,
|
||||
metadata.len() as i64,
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
)
|
||||
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
|
||||
@@ -472,7 +499,14 @@ async fn clip_range(
|
||||
)
|
||||
.await?;
|
||||
if state.config.read().await.clip_notify {
|
||||
state.app_handle.notification().builder().title("BiliShadowReplay - 切片完成").body(format!("生成了房间 {} 的切片: {}", room_id, filename)).show().unwrap();
|
||||
state
|
||||
.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 切片完成")
|
||||
.body(format!("生成了房间 {} 的切片: {}", room_id, filename))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
Ok(video)
|
||||
}
|
||||
@@ -488,10 +522,10 @@ async fn upload_procedure(
|
||||
) -> Result<String, String> {
|
||||
let account = state.db.get_account(uid).await?;
|
||||
// get video info from dbs
|
||||
let video = state.db.get_video(video_id).await?;
|
||||
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.file);
|
||||
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 {
|
||||
@@ -499,18 +533,13 @@ async fn upload_procedure(
|
||||
if let Ok(ret) = state.client.submit_video(&account, &profile, &video).await {
|
||||
// update video status and details
|
||||
// 1 means uploaded
|
||||
state
|
||||
.db
|
||||
.update_video(
|
||||
video_id,
|
||||
1,
|
||||
&ret.bvid,
|
||||
&profile.title,
|
||||
&profile.desc,
|
||||
&profile.tag,
|
||||
profile.tid,
|
||||
)
|
||||
.await?;
|
||||
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(
|
||||
@@ -519,7 +548,14 @@ async fn upload_procedure(
|
||||
)
|
||||
.await?;
|
||||
if state.config.read().await.post_notify {
|
||||
state.app_handle.notification().builder().title("BiliShadowReplay - 投稿成功").body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid)).show().unwrap();
|
||||
state
|
||||
.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 投稿成功")
|
||||
.body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
Ok(ret.bvid)
|
||||
} else {
|
||||
@@ -566,7 +602,7 @@ async fn delete_archive(
|
||||
room_id: u64,
|
||||
ts: u64,
|
||||
) -> Result<(), String> {
|
||||
state.recorder_manager.delete_archive(room_id, ts).await;
|
||||
state.recorder_manager.delete_archive(room_id, ts).await?;
|
||||
state
|
||||
.db
|
||||
.new_message(
|
||||
@@ -592,6 +628,15 @@ async fn send_danmaku(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_danmu_record(
|
||||
state: tauri::State<'_, State>,
|
||||
room_id: u64,
|
||||
ts: u64,
|
||||
) -> Result<Vec<DanmuEntry>, String> {
|
||||
Ok(state.recorder_manager.get_danmu(room_id, ts).await?)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct AccountInfo {
|
||||
pub primary_uid: u64,
|
||||
@@ -705,14 +750,57 @@ async fn delete_video(state: tauri::State<'_, State>, id: i64) -> Result<(), Str
|
||||
Ok(state.db.delete_video(id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_video_typelist(
|
||||
state: tauri::State<'_, State>,
|
||||
) -> Result<Vec<recorder::bilibili::response::Typelist>, String> {
|
||||
let account = state
|
||||
.db
|
||||
.get_account(state.config.read().await.primary_uid)
|
||||
.await?;
|
||||
Ok(state.client.get_video_typelist(&account).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Setup log
|
||||
simplelog::CombinedLogger::init(vec![simplelog::TermLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
simplelog::TerminalMode::Mixed,
|
||||
simplelog::ColorChoice::Auto,
|
||||
)])
|
||||
simplelog::CombinedLogger::init(vec![
|
||||
simplelog::TermLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
simplelog::TerminalMode::Mixed,
|
||||
simplelog::ColorChoice::Auto,
|
||||
),
|
||||
simplelog::WriteLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
File::create("bsr.log").unwrap(),
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
// Setup ffmpeg
|
||||
@@ -814,12 +902,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Ok(account) = account {
|
||||
for room in initial_rooms {
|
||||
if let Err(e) = recorder_manager_clone
|
||||
.add_recorder(
|
||||
&webid,
|
||||
&db_clone,
|
||||
&account,
|
||||
room.room_id,
|
||||
)
|
||||
.add_recorder(&webid, &db_clone, &account, room.room_id)
|
||||
.await
|
||||
{
|
||||
log::error!("error when adding initial rooms: {}", e);
|
||||
@@ -879,6 +962,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
get_disk_info,
|
||||
send_danmaku,
|
||||
update_notify,
|
||||
get_danmu_record,
|
||||
get_video_typelist,
|
||||
export_to_file
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
pub mod errors;
|
||||
pub mod profile;
|
||||
pub mod response;
|
||||
use crate::db::AccountRow;
|
||||
use crate::database::account::AccountRow;
|
||||
|
||||
use super::StreamType;
|
||||
use errors::BiliClientError;
|
||||
use pct_str::PctString;
|
||||
use pct_str::URIReserved;
|
||||
use profile::Profile;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use response::Format;
|
||||
use response::GeneralResponse;
|
||||
use response::PostVideoMetaResponse;
|
||||
use response::PreuploadResponse;
|
||||
@@ -18,152 +18,14 @@ 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::sync::RwLock;
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayUrlResponse {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
pub ttl: i64,
|
||||
pub data: Data,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Data {
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// BiliClient is thread safe
|
||||
pub struct BiliClient {
|
||||
client: Client,
|
||||
headers: reqwest::header::HeaderMap,
|
||||
extra: RwLock<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct RoomInfo {
|
||||
pub live_status: u8,
|
||||
@@ -196,20 +58,81 @@ pub struct QrStatus {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(&self) -> String {
|
||||
format!("{}{}{}", self.host, self.path, "index.m3u8")
|
||||
}
|
||||
|
||||
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()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
{
|
||||
Ok(BiliClient {
|
||||
client,
|
||||
headers,
|
||||
extra: RwLock::new("".into()),
|
||||
})
|
||||
if let Ok(client) = Client::builder().timeout(Duration::from_secs(3)).build() {
|
||||
Ok(BiliClient { client, headers })
|
||||
} else {
|
||||
Err(BiliClientError::InitClientError)
|
||||
}
|
||||
@@ -399,10 +322,10 @@ impl BiliClient {
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
room_id: u64,
|
||||
) -> Result<(String, StreamType), BiliClientError> {
|
||||
) -> Result<BiliStream, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let res: PlayUrlResponse = self
|
||||
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",
|
||||
@@ -412,50 +335,50 @@ impl BiliClient {
|
||||
.send().await?
|
||||
.json().await?;
|
||||
if res.code == 0 {
|
||||
if let Some(stream) = res.data.playurl_info.playurl.stream.first() {
|
||||
// Get fmp4 format
|
||||
if let Some(format) = stream.format.get(1) {
|
||||
self.get_url_from_format(format)
|
||||
.await
|
||||
.ok_or(BiliClientError::InvalidFormat)
|
||||
.map(|url| (url, StreamType::FMP4))
|
||||
} else if let Some(format) = stream.format.first() {
|
||||
self.get_url_from_format(format)
|
||||
.await
|
||||
.ok_or(BiliClientError::InvalidFormat)
|
||||
.map(|url| (url, StreamType::TS))
|
||||
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_url_from_format(&self, format: &Format) -> Option<String> {
|
||||
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() {
|
||||
let base_url = codec.base_url.strip_suffix('?').unwrap();
|
||||
let extra = "?".to_owned() + &url_info.extra.clone();
|
||||
let host = url_info.host.clone();
|
||||
let url = format!("{}{}", host, base_url);
|
||||
*self.extra.write().await = extra;
|
||||
Some(url)
|
||||
Ok(BiliStream::new(
|
||||
StreamType::FMP4,
|
||||
&codec.base_url,
|
||||
&url_info.host,
|
||||
&url_info.extra,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
Err(BiliClientError::InvalidFormat)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
Err(BiliClientError::InvalidFormat)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_index_content(&self, url: &String) -> Result<String, BiliClientError> {
|
||||
Ok(self
|
||||
.client
|
||||
.get(url.to_owned() + self.extra.read().await.as_str())
|
||||
.get(url.to_owned())
|
||||
.headers(self.headers.clone())
|
||||
.send()
|
||||
.await?
|
||||
@@ -464,7 +387,6 @@ impl BiliClient {
|
||||
}
|
||||
|
||||
pub async fn download_ts(&self, url: &str, file_path: &str) -> Result<u64, BiliClientError> {
|
||||
let url = url.to_owned() + self.extra.read().await.as_str();
|
||||
let res = self
|
||||
.client
|
||||
.get(url)
|
||||
@@ -832,4 +754,31 @@ impl BiliClient {
|
||||
.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",
|
||||
InvalidFormat = "Invalid stream format",
|
||||
EmptyCache = "Empty cache",
|
||||
ClientError{err: reqwest::Error} = "Client error",
|
||||
IOError{err: std::io::Error} = "IO error",
|
||||
ClientError{err: reqwest::Error} = "Client error: {err}",
|
||||
IOError{err: std::io::Error} = "IO error: {err}",
|
||||
}
|
||||
|
||||
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)]
|
||||
pub struct GeneralResponse {
|
||||
@@ -13,6 +15,8 @@ pub struct GeneralResponse {
|
||||
pub enum Data {
|
||||
VideoSubmit(VideoSubmitData),
|
||||
Cover(CoverData),
|
||||
RoomPlayInfo(RoomPlayInfoData),
|
||||
VideoTypeList(VideoTypeListData),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
@@ -41,3 +45,183 @@ pub struct PostVideoMetaResponse {
|
||||
pub key: String,
|
||||
pub upload_id: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoomPlayInfoData {
|
||||
#[serde(rename = "room_id")]
|
||||
pub room_id: i64,
|
||||
#[serde(rename = "short_id")]
|
||||
pub short_id: i64,
|
||||
pub uid: i64,
|
||||
#[serde(rename = "is_hidden")]
|
||||
pub is_hidden: bool,
|
||||
#[serde(rename = "is_locked")]
|
||||
pub is_locked: bool,
|
||||
#[serde(rename = "is_portrait")]
|
||||
pub is_portrait: bool,
|
||||
#[serde(rename = "live_status")]
|
||||
pub live_status: i64,
|
||||
#[serde(rename = "hidden_till")]
|
||||
pub hidden_till: i64,
|
||||
#[serde(rename = "lock_till")]
|
||||
pub lock_till: i64,
|
||||
pub encrypted: bool,
|
||||
#[serde(rename = "pwd_verified")]
|
||||
pub pwd_verified: bool,
|
||||
#[serde(rename = "live_time")]
|
||||
pub live_time: i64,
|
||||
#[serde(rename = "room_shield")]
|
||||
pub room_shield: i64,
|
||||
#[serde(rename = "all_special_types")]
|
||||
pub all_special_types: Vec<i64>,
|
||||
#[serde(rename = "playurl_info")]
|
||||
pub playurl_info: PlayurlInfo,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayurlInfo {
|
||||
#[serde(rename = "conf_json")]
|
||||
pub conf_json: String,
|
||||
pub playurl: Playurl,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Playurl {
|
||||
pub cid: i64,
|
||||
#[serde(rename = "g_qn_desc")]
|
||||
pub g_qn_desc: Vec<GQnDesc>,
|
||||
pub stream: Vec<Stream>,
|
||||
#[serde(rename = "p2p_data")]
|
||||
pub p2p_data: P2pData,
|
||||
#[serde(rename = "dolby_qn")]
|
||||
pub dolby_qn: Value,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GQnDesc {
|
||||
pub qn: i64,
|
||||
pub desc: String,
|
||||
#[serde(rename = "hdr_desc")]
|
||||
pub hdr_desc: String,
|
||||
#[serde(rename = "attr_desc")]
|
||||
pub attr_desc: Value,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Stream {
|
||||
#[serde(rename = "protocol_name")]
|
||||
pub protocol_name: String,
|
||||
pub format: Vec<Format>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Format {
|
||||
#[serde(rename = "format_name")]
|
||||
pub format_name: String,
|
||||
pub codec: Vec<Codec>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Codec {
|
||||
#[serde(rename = "codec_name")]
|
||||
pub codec_name: String,
|
||||
#[serde(rename = "current_qn")]
|
||||
pub current_qn: i64,
|
||||
#[serde(rename = "accept_qn")]
|
||||
pub accept_qn: Vec<i64>,
|
||||
#[serde(rename = "base_url")]
|
||||
pub base_url: String,
|
||||
#[serde(rename = "url_info")]
|
||||
pub url_info: Vec<UrlInfo>,
|
||||
#[serde(rename = "hdr_qn")]
|
||||
pub hdr_qn: Value,
|
||||
#[serde(rename = "dolby_type")]
|
||||
pub dolby_type: i64,
|
||||
#[serde(rename = "attr_name")]
|
||||
pub attr_name: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UrlInfo {
|
||||
pub host: String,
|
||||
pub extra: String,
|
||||
#[serde(rename = "stream_ttl")]
|
||||
pub stream_ttl: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct P2pData {
|
||||
pub p2p: bool,
|
||||
#[serde(rename = "p2p_type")]
|
||||
pub p2p_type: i64,
|
||||
#[serde(rename = "m_p2p")]
|
||||
pub m_p2p: bool,
|
||||
#[serde(rename = "m_servers")]
|
||||
pub m_servers: Value,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoTypeListData {
|
||||
pub typelist: Vec<Typelist>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Typelist {
|
||||
pub id: i64,
|
||||
pub parent: i64,
|
||||
#[serde(rename = "parent_name")]
|
||||
pub parent_name: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub desc: String,
|
||||
#[serde(rename = "intro_original")]
|
||||
pub intro_original: String,
|
||||
#[serde(rename = "intro_copy")]
|
||||
pub intro_copy: String,
|
||||
pub notice: String,
|
||||
#[serde(rename = "copy_right")]
|
||||
pub copy_right: i64,
|
||||
pub show: bool,
|
||||
pub rank: i64,
|
||||
pub children: Vec<Children>,
|
||||
#[serde(rename = "max_video_count")]
|
||||
pub max_video_count: i64,
|
||||
#[serde(rename = "request_id")]
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Children {
|
||||
pub id: i64,
|
||||
pub parent: i64,
|
||||
#[serde(rename = "parent_name")]
|
||||
pub parent_name: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub desc: String,
|
||||
#[serde(rename = "intro_original")]
|
||||
pub intro_original: String,
|
||||
#[serde(rename = "intro_copy")]
|
||||
pub intro_copy: String,
|
||||
pub notice: String,
|
||||
#[serde(rename = "copy_right")]
|
||||
pub copy_right: i64,
|
||||
pub show: bool,
|
||||
pub rank: i64,
|
||||
#[serde(rename = "max_video_count")]
|
||||
pub max_video_count: i64,
|
||||
#[serde(rename = "request_id")]
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::db::{AccountRow, Database, RecordRow};
|
||||
use crate::database::{account::AccountRow, record::RecordRow, Database};
|
||||
use crate::recorder::bilibili::UserInfo;
|
||||
use crate::recorder::danmu::DanmuEntry;
|
||||
use crate::recorder::RecorderError;
|
||||
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
|
||||
use crate::Config;
|
||||
use custom_error::custom_error;
|
||||
use dashmap::DashMap;
|
||||
use hyper::Method;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Request, Response, Server,
|
||||
@@ -70,7 +72,6 @@ impl From<RecorderManagerError> for String {
|
||||
}
|
||||
|
||||
impl RecorderManager {
|
||||
|
||||
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
|
||||
RecorderManager {
|
||||
app_handle,
|
||||
@@ -122,6 +123,10 @@ impl RecorderManager {
|
||||
if recorder.is_none() {
|
||||
return Err(RecorderManagerError::NotFound { room_id });
|
||||
}
|
||||
recorder.unwrap().1.stop().await;
|
||||
// remove related cache folder
|
||||
let cache_folder = format!("{}/{}", self.config.read().await.cache, room_id);
|
||||
tokio::fs::remove_dir_all(cache_folder).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -218,9 +223,22 @@ impl RecorderManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_archive(&self, room_id: u64, ts: u64) {
|
||||
pub async fn delete_archive(&self, room_id: u64, ts: u64) -> Result<(), RecorderManagerError> {
|
||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
||||
recorder.delete_archive(ts).await;
|
||||
recorder.delete_archive(ts).await?
|
||||
}
|
||||
Err(RecorderManagerError::NotFound { room_id })
|
||||
}
|
||||
|
||||
pub async fn get_danmu(
|
||||
&self,
|
||||
room_id: u64,
|
||||
live_id: u64,
|
||||
) -> Result<Vec<DanmuEntry>, RecorderManagerError> {
|
||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
||||
Ok(recorder.get_danmu_record(live_id).await)
|
||||
} else {
|
||||
Err(RecorderManagerError::NotFound { room_id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +256,18 @@ impl RecorderManager {
|
||||
let recorders = recorders.clone();
|
||||
let config = config.clone();
|
||||
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 path = req.uri().path();
|
||||
let path_segs: Vec<&str> = path.split('/').collect();
|
||||
@@ -280,7 +310,7 @@ impl RecorderManager {
|
||||
} else {
|
||||
// try to find requested ts file in recorder's cache
|
||||
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
|
||||
let ts_file = format!("{}/{}", cache_path, path);
|
||||
let ts_file = format!("{}/{}", cache_path, path.replace("%7C", "|"));
|
||||
let recorder = recorders.get(&room_id);
|
||||
if recorder.is_none() {
|
||||
return Ok::<_, Infallible>(
|
||||
|
||||
@@ -8,12 +8,9 @@
|
||||
"title": "BiliBili ShadowReplay",
|
||||
"width": 1300,
|
||||
"height": 600,
|
||||
"transparent": true,
|
||||
"transparent": false,
|
||||
"decorations": false,
|
||||
"theme": "Light",
|
||||
"windowEffects": {
|
||||
"effects": ["tabbed", "mica"]
|
||||
}
|
||||
"theme": "Light"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
139
src/App.svelte
@@ -1,84 +1,85 @@
|
||||
<script lang="ts">
|
||||
import Room from "./lib/Room.svelte";
|
||||
import BSidebar from "./lib/BSidebar.svelte";
|
||||
import Summary from "./lib/Summary.svelte";
|
||||
import Setting from "./lib/Setting.svelte";
|
||||
import Account from "./lib/Account.svelte";
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import Messages from "./lib/Messages.svelte";
|
||||
import About from "./lib/About.svelte";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
let active = "#总览";
|
||||
let room_count = 0;
|
||||
let message_cnt = 0;
|
||||
let use_titlebar = platform() == "windows";
|
||||
import Room from "./lib/Room.svelte";
|
||||
import BSidebar from "./lib/BSidebar.svelte";
|
||||
import Summary from "./lib/Summary.svelte";
|
||||
import Setting from "./lib/Setting.svelte";
|
||||
import Account from "./lib/Account.svelte";
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import Messages from "./lib/Messages.svelte";
|
||||
import About from "./lib/About.svelte";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
let active = "#总览";
|
||||
let room_count = 0;
|
||||
let message_cnt = 0;
|
||||
let use_titlebar = platform() == "windows";
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if use_titlebar}
|
||||
<TitleBar />
|
||||
{/if}
|
||||
<div class="wrap">
|
||||
<div class="sidebar">
|
||||
<BSidebar bind:activeUrl={active} {room_count} {message_cnt} />
|
||||
</div>
|
||||
<div class="content">
|
||||
<!-- switch component by active -->
|
||||
<div class="page" class:visible={active == "#总览"}>
|
||||
<Summary />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#直播间"}>
|
||||
<Room bind:room_count />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#消息"}>
|
||||
<Messages bind:message_cnt />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#账号"}>
|
||||
<Account />
|
||||
</div>
|
||||
<!-- <div class="page" class:visible={active == "#自动化"}>
|
||||
{#if use_titlebar}
|
||||
<TitleBar />
|
||||
{/if}
|
||||
<div class="wrap">
|
||||
<div class="sidebar">
|
||||
<BSidebar bind:activeUrl={active} {room_count} {message_cnt} />
|
||||
</div>
|
||||
<div class="content">
|
||||
<!-- switch component by active -->
|
||||
<div class="page" class:visible={active == "#总览"}>
|
||||
<Summary />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#直播间"}>
|
||||
<Room bind:room_count />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#消息"}>
|
||||
<Messages bind:message_cnt />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#账号"}>
|
||||
<Account />
|
||||
</div>
|
||||
<!-- <div class="page" class:visible={active == "#自动化"}>
|
||||
<div>自动化[开发中]</div>
|
||||
</div> -->
|
||||
<div class="page" class:visible={active == "#设置"}>
|
||||
<Setting />
|
||||
</div>
|
||||
<div class="page" class:visible={active == "#关于"}>
|
||||
<About />
|
||||
</div>
|
||||
</div>
|
||||
<div class="page" class:visible={active == "#设置"}>
|
||||
<Setting />
|
||||
</div>
|
||||
<div class="page" class:visible={active == "#关于"}>
|
||||
<About />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1 !important;
|
||||
max-height: fit-content !important;
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
.visible {
|
||||
opacity: 1 !important;
|
||||
max-height: fit-content !important;
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
|
||||
.page {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateX(100%);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
}
|
||||
.page {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateX(100%);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100vh;
|
||||
}
|
||||
.content {
|
||||
height: 100vh;
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,9 +17,16 @@
|
||||
import html2canvas from "html2canvas";
|
||||
import type { AccountInfo, RecordItem } from "./lib/db";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { ClapperboardPlaySolid, PlayOutline } from "flowbite-svelte-icons";
|
||||
import type { Profile, VideoItem, Config } from "./lib/interface";
|
||||
import {
|
||||
AngleRightOutline,
|
||||
AngleLeftOutline,
|
||||
ClapperboardPlaySolid,
|
||||
PlayOutline,
|
||||
} from "flowbite-svelte-icons";
|
||||
import type { Profile, VideoItem, Config, Marker } from "./lib/interface";
|
||||
import { onMount } from "svelte";
|
||||
import TypeSelect from "./lib/TypeSelect.svelte";
|
||||
import MarkerPanel from "./lib/MarkerPanel.svelte";
|
||||
|
||||
let use_titlebar = platform() == "windows";
|
||||
|
||||
@@ -46,6 +53,10 @@
|
||||
return default_profile();
|
||||
}
|
||||
|
||||
$: {
|
||||
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
|
||||
}
|
||||
|
||||
function default_profile(): Profile {
|
||||
return {
|
||||
videos: [],
|
||||
@@ -122,10 +133,21 @@
|
||||
(a: RecordItem) => {
|
||||
console.log(a);
|
||||
archive = a;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title}`);
|
||||
appWindow.setTitle(`[${room_id}][${format_ts(ts)}]${archive.title}`);
|
||||
},
|
||||
);
|
||||
|
||||
function update_title(str: string) {
|
||||
appWindow.setTitle(
|
||||
`[${room_id}][${format_ts(ts)}]${archive.title} - ${str}`,
|
||||
);
|
||||
}
|
||||
|
||||
function format_ts(ts: number) {
|
||||
const date = new Date(ts * 1000);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
async function get_video_list() {
|
||||
videos = (
|
||||
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
|
||||
@@ -159,30 +181,34 @@
|
||||
}
|
||||
loading = true;
|
||||
let new_cover = generateCover();
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成中`);
|
||||
let new_video = (await invoke("clip_range", {
|
||||
roomId: room_id,
|
||||
cover: new_cover,
|
||||
ts: ts,
|
||||
x: start,
|
||||
y: end,
|
||||
})) as VideoItem;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成成功`);
|
||||
console.log("video file generatd:", video);
|
||||
await get_video_list();
|
||||
video_selected = new_video.id;
|
||||
video = videos.find((v) => {
|
||||
return v.value == new_video.id;
|
||||
});
|
||||
cover = new_video.cover;
|
||||
loading = false;
|
||||
update_title(`切片生成中`);
|
||||
try {
|
||||
let new_video = (await invoke("clip_range", {
|
||||
roomId: room_id,
|
||||
cover: new_cover,
|
||||
ts: ts,
|
||||
x: start,
|
||||
y: end,
|
||||
})) as VideoItem;
|
||||
update_title(`切片生成成功`);
|
||||
console.log("video file generatd:", video);
|
||||
await get_video_list();
|
||||
video_selected = new_video.id;
|
||||
video = videos.find((v) => {
|
||||
return v.value == new_video.id;
|
||||
});
|
||||
cover = new_video.cover;
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
alert("Err generating clip: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
async function do_post() {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿上传中`);
|
||||
update_title(`投稿上传中`);
|
||||
loading = true;
|
||||
// render cover with text
|
||||
const ecapture = document.getElementById("capture");
|
||||
@@ -201,13 +227,13 @@
|
||||
})
|
||||
.then(async () => {
|
||||
loading = false;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿成功`);
|
||||
update_title(`投稿成功`);
|
||||
video_selected = 0;
|
||||
await get_video_list();
|
||||
})
|
||||
.catch((e) => {
|
||||
loading = false;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿失败`);
|
||||
update_title(`投稿失败`);
|
||||
alert(e);
|
||||
});
|
||||
}
|
||||
@@ -217,24 +243,39 @@
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 删除中`);
|
||||
update_title(`删除中`);
|
||||
await invoke("delete_video", { id: video_selected });
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 删除成功`);
|
||||
update_title(`删除成功`);
|
||||
loading = false;
|
||||
video_selected = 0;
|
||||
video = null;
|
||||
cover = "";
|
||||
await get_video_list();
|
||||
}
|
||||
let player;
|
||||
let lpanel_collapsed = true;
|
||||
let rpanel_collapsed = false;
|
||||
let markers: Marker[] = [];
|
||||
// load markers from local storage
|
||||
markers = JSON.parse(
|
||||
window.localStorage.getItem(`markers:${room_id}:${ts}`) || "[]",
|
||||
);
|
||||
$: {
|
||||
// makers changed, save to local storage
|
||||
window.localStorage.setItem(
|
||||
`markers:${room_id}:${ts}`,
|
||||
JSON.stringify(markers),
|
||||
);
|
||||
}
|
||||
|
||||
// when window resize, update post panel height
|
||||
onMount(() => {
|
||||
let post_panel = document.getElementById("post-panel");
|
||||
if (post_panel) {
|
||||
if (post_panel && !rpanel_collapsed) {
|
||||
post_panel.style.height = `calc(100vh - 35px)`;
|
||||
}
|
||||
window.addEventListener("resize", () => {
|
||||
if (post_panel) {
|
||||
if (post_panel && !rpanel_collapsed) {
|
||||
post_panel.style.height = `calc(100vh - 35px)`;
|
||||
}
|
||||
});
|
||||
@@ -245,21 +286,78 @@
|
||||
{#if use_titlebar}
|
||||
<TitleBar dark />
|
||||
{/if}
|
||||
<div class="flex flex-row">
|
||||
<div class="w-3/4">
|
||||
<Player bind:start bind:end {port} {room_id} {ts} />
|
||||
<div class="flex flex-row overflow-hidden">
|
||||
<div
|
||||
class="flex relative h-screen border-solid bg-gray-950 border-r-2 border-gray-800 z-[39]"
|
||||
class:w14={!lpanel_collapsed}
|
||||
>
|
||||
<div class="w-full" hidden={lpanel_collapsed}>
|
||||
<MarkerPanel
|
||||
{archive}
|
||||
bind:markers
|
||||
on:markerClick={(e) => {
|
||||
player.seek(e.detail.offset);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="collapse-btn lp"
|
||||
on:click={() => {
|
||||
lpanel_collapsed = !lpanel_collapsed;
|
||||
}}
|
||||
>
|
||||
{#if lpanel_collapsed}
|
||||
<AngleRightOutline color="white" />
|
||||
{:else}
|
||||
<AngleLeftOutline color="white" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-hidden h-screen w-full relative">
|
||||
<Player
|
||||
bind:start
|
||||
bind:end
|
||||
bind:this={player}
|
||||
{port}
|
||||
{room_id}
|
||||
{ts}
|
||||
{markers}
|
||||
on:markerAdd={(e) => {
|
||||
markers.push({
|
||||
offset: e.detail.offset,
|
||||
realtime: e.detail.realtime,
|
||||
content: "[空标记点]",
|
||||
});
|
||||
markers = markers.sort((a, b) => a.offset - b.offset);
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
<Modal title="预览" bind:open={preview} autoclose>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video src={video_src} controls />
|
||||
</Modal>
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 h-screen overflow-hidden border-solid bg-gray-50 border-l-2 border-slate-200 z-[49]"
|
||||
class="flex relative h-screen border-solid bg-gray-950 border-l-2 border-gray-800 text-white"
|
||||
class:w14={!rpanel_collapsed}
|
||||
>
|
||||
<button
|
||||
class="collapse-btn rp"
|
||||
on:click={() => {
|
||||
rpanel_collapsed = !rpanel_collapsed;
|
||||
}}
|
||||
>
|
||||
{#if rpanel_collapsed}
|
||||
<AngleLeftOutline color="white" />
|
||||
{:else}
|
||||
<AngleRightOutline color="white" />
|
||||
{/if}
|
||||
</button>
|
||||
<div
|
||||
id="post-panel"
|
||||
class="mt-6 overflow-y-auto overflow-x-hidden p-6"
|
||||
class="mt-6 overflow-y-auto overflow-x-hidden p-4 py-6 w-full text-white"
|
||||
class:titlebar={use_titlebar}
|
||||
hidden={rpanel_collapsed}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
{#if video}
|
||||
@@ -271,7 +369,7 @@
|
||||
>
|
||||
<div id="capture" class="cover-wrap relative cursor-pointer">
|
||||
<div
|
||||
class="cover-text absolute py-2 px-8"
|
||||
class="cover-text absolute py-1 px-8"
|
||||
class:play-icon={false}
|
||||
>
|
||||
{cover_text}
|
||||
@@ -292,7 +390,12 @@
|
||||
class="mb-2"
|
||||
/>
|
||||
<ButtonGroup>
|
||||
<Button on:click={generate_clip} disabled={loading} color="primary">
|
||||
<Button
|
||||
on:click={generate_clip}
|
||||
disabled={loading}
|
||||
color="primary"
|
||||
class="w-3/4"
|
||||
>
|
||||
{#if loading}
|
||||
<Spinner class="me-3" size="4" />
|
||||
{:else}
|
||||
@@ -302,6 +405,7 @@
|
||||
>
|
||||
<Button
|
||||
color="red"
|
||||
class="w-1/4"
|
||||
disabled={!loading && !video}
|
||||
on:click={delete_video}>删除</Button
|
||||
>
|
||||
@@ -309,51 +413,17 @@
|
||||
</div>
|
||||
<Hr />
|
||||
<Label class="mt-4">标题</Label>
|
||||
<Input
|
||||
size="sm"
|
||||
bind:value={profile.title}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Input size="sm" bind:value={profile.title} />
|
||||
<Label class="mt-2">封面文本</Label>
|
||||
<Textarea bind:value={cover_text} />
|
||||
<Label class="mt-2">描述</Label>
|
||||
<Textarea
|
||||
bind:value={profile.desc}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Textarea bind:value={profile.desc} />
|
||||
<Label class="mt-2">标签</Label>
|
||||
<Input
|
||||
size="sm"
|
||||
bind:value={profile.tag}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Input size="sm" bind:value={profile.tag} />
|
||||
<Label class="mt-2">动态</Label>
|
||||
<Textarea
|
||||
bind:value={profile.dynamic}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Textarea bind:value={profile.dynamic} />
|
||||
<Label class="mt-2">视频分区</Label>
|
||||
<Input size="sm" value="动画 - 综合" disabled />
|
||||
<TypeSelect bind:value={profile.tid} />
|
||||
<Label class="mt-2">投稿账号</Label>
|
||||
<Select size="sm" items={accounts} bind:value={uid_selected} />
|
||||
{#if video}
|
||||
@@ -385,6 +455,7 @@
|
||||
.cover-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
font-weight: bold;
|
||||
color: rgb(255, 127, 0);
|
||||
text-shadow:
|
||||
@@ -397,4 +468,28 @@
|
||||
-2px 2px 0 rgba(255, 255, 255, 0.5),
|
||||
2px 2px 0 rgba(255, 255, 255, 0.5); /* 创建细腻的白色描边效果 */
|
||||
}
|
||||
.w14 {
|
||||
@apply w-1/4;
|
||||
}
|
||||
.collapse-btn {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
top: 50%;
|
||||
width: 20px;
|
||||
height: 40px;
|
||||
}
|
||||
.collapse-btn.rp {
|
||||
left: -20px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
border: 2px solid rgb(31 41 55 / var(--tw-border-opacity));
|
||||
border-right: none;
|
||||
background-color: rgb(3 7 18 / var(--tw-bg-opacity));
|
||||
}
|
||||
.collapse-btn.lp {
|
||||
right: -20px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border: 2px solid rgb(31 41 55 / var(--tw-border-opacity));
|
||||
border-left: none;
|
||||
background-color: rgb(3 7 18 / var(--tw-bg-opacity));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
站直播流,并生成视频投稿的工具。
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
项目地址: <a href="https://github.com/Xinrea/bili-shadowreplay"
|
||||
项目地址: <a
|
||||
target="_blank"
|
||||
href="https://github.com/Xinrea/bili-shadowreplay"
|
||||
>https://github.com/Xinrea/bili-shadowreplay</a
|
||||
>
|
||||
</p>
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
TableBodyCell,
|
||||
Modal,
|
||||
ButtonGroup,
|
||||
SpeedDial,
|
||||
Listgroup,
|
||||
ListgroupItem,
|
||||
Textarea,
|
||||
Hr,
|
||||
} from "flowbite-svelte";
|
||||
import Image from "./Image.svelte";
|
||||
import QRCode from "qrcode";
|
||||
@@ -32,6 +37,9 @@
|
||||
let oauth_key = "";
|
||||
let check_interval = null;
|
||||
|
||||
let manualModal = false;
|
||||
let cookie_str = "";
|
||||
|
||||
async function handle_qr() {
|
||||
if (check_interval) {
|
||||
clearInterval(check_interval);
|
||||
@@ -52,7 +60,7 @@
|
||||
async function check_qr() {
|
||||
let qr_status: { code: number; cookies: string } = await invoke(
|
||||
"get_qr_status",
|
||||
{ qrcodeKey: oauth_key }
|
||||
{ qrcodeKey: oauth_key },
|
||||
);
|
||||
if (qr_status.code == 0) {
|
||||
clearInterval(check_interval);
|
||||
@@ -61,6 +69,20 @@
|
||||
addModal = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add_cookie() {
|
||||
if (cookie_str == "") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke("add_account", { cookies: cookie_str });
|
||||
await update_accounts();
|
||||
cookie_str = "";
|
||||
manualModal = false;
|
||||
} catch (e) {
|
||||
alert("Err adding cookie:" + e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-8 pt-12 h-full overflow-auto">
|
||||
@@ -116,16 +138,23 @@
|
||||
</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>
|
||||
<SpeedDial defaultClass="absolute end-6 bottom-6" placement="top-end">
|
||||
<Listgroup active>
|
||||
<ListgroupItem
|
||||
class="flex gap-2 md:px-5"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
requestAnimationFrame(handle_qr);
|
||||
}}>扫码添加</ListgroupItem
|
||||
>
|
||||
<ListgroupItem
|
||||
class="flex gap-2 md:px-5"
|
||||
on:click={() => {
|
||||
manualModal = true;
|
||||
}}>手动添加</ListgroupItem
|
||||
>
|
||||
</Listgroup>
|
||||
</SpeedDial>
|
||||
|
||||
<Modal
|
||||
title="请使用 BiliBili App 扫码登录"
|
||||
@@ -137,3 +166,20 @@
|
||||
<canvas id="qr" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="请粘贴 BiliBili 账号 Cookie"
|
||||
bind:open={manualModal}
|
||||
size="sm"
|
||||
autoclose
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center h-full">
|
||||
<Textarea bind:value={cookie_str} />
|
||||
<Button
|
||||
class="mt-4"
|
||||
on:click={() => {
|
||||
add_cookie();
|
||||
}}>添加</Button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
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,16 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { AccountInfo, AccountItem } from "./db";
|
||||
import type { AccountInfo } from "./db";
|
||||
import type { Marker } from "./interface";
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface DanmuEntry {
|
||||
ts: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export let port;
|
||||
export let room_id;
|
||||
export let ts;
|
||||
export let start = 0;
|
||||
export let end = 0;
|
||||
export let markers: Marker[] = [];
|
||||
export function seek(offset: number) {
|
||||
video.currentTime = offset;
|
||||
}
|
||||
let video: HTMLVideoElement;
|
||||
let show_detail = false;
|
||||
let global_offset = 0;
|
||||
|
||||
// TODO get custom tag from shaka player instead of manual parsing
|
||||
async function meta_parse() {
|
||||
fetch(`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`)
|
||||
.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 init() {
|
||||
const video = document.getElementById("video") as HTMLVideoElement;
|
||||
video = document.getElementById("video") as HTMLVideoElement;
|
||||
const ui = video["ui"];
|
||||
const controls = ui.getControls();
|
||||
const player = controls.getPlayer();
|
||||
@@ -26,6 +61,14 @@
|
||||
// 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).ui = ui;
|
||||
|
||||
player.addEventListener("ended", async () => {
|
||||
location.reload();
|
||||
});
|
||||
player.addEventListener("manifestloaded", (event) => {
|
||||
console.log("Manifest loaded:", event);
|
||||
});
|
||||
|
||||
try {
|
||||
await player.load(
|
||||
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`,
|
||||
@@ -39,15 +82,21 @@
|
||||
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.querySelector(
|
||||
".shaka-back-to-overflow-button .material-icons-round",
|
||||
).innerHTML = "arrow_back_ios_new";
|
||||
|
||||
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
|
||||
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
|
||||
const shakaBottomControls = document.querySelector(
|
||||
".shaka-bottom-controls.shaka-no-propagation",
|
||||
@@ -66,55 +115,104 @@
|
||||
`;
|
||||
shakaBottomControls.appendChild(selfSeekbar);
|
||||
|
||||
// add a account select
|
||||
const accountSelect = document.createElement("select");
|
||||
accountSelect.style.height = "30px";
|
||||
accountSelect.style.minWidth = "100px";
|
||||
accountSelect.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||
accountSelect.style.color = "white";
|
||||
accountSelect.style.border = "1px solid gray";
|
||||
accountSelect.style.padding = "0 10px";
|
||||
accountSelect.style.boxSizing = "border-box";
|
||||
accountSelect.style.fontSize = "1em";
|
||||
|
||||
// get accounts from tauri
|
||||
const account_info = (await invoke("get_accounts")) as AccountInfo;
|
||||
account_info.accounts.forEach((account) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = account.uid.toString();
|
||||
option.text = account.name;
|
||||
accountSelect.appendChild(option);
|
||||
});
|
||||
// add a danmaku send input
|
||||
const danmakuInput = document.createElement("input");
|
||||
danmakuInput.type = "text";
|
||||
danmakuInput.placeholder = "回车发送弹幕";
|
||||
danmakuInput.style.width = "50%";
|
||||
danmakuInput.style.height = "30px";
|
||||
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||
danmakuInput.style.color = "white";
|
||||
danmakuInput.style.border = "1px solid gray";
|
||||
danmakuInput.style.padding = "0 10px";
|
||||
danmakuInput.style.boxSizing = "border-box";
|
||||
danmakuInput.style.fontSize = "1em";
|
||||
danmakuInput.addEventListener("keydown", async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = danmakuInput.value;
|
||||
if (value) {
|
||||
// get account uid from select
|
||||
const uid = parseInt(accountSelect.value);
|
||||
await invoke("send_danmaku", {
|
||||
uid,
|
||||
roomId: room_id,
|
||||
ts,
|
||||
message: value,
|
||||
});
|
||||
danmakuInput.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
// add to shaka-spacer
|
||||
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,
|
||||
ts: ts,
|
||||
})) as DanmuEntry[];
|
||||
|
||||
console.log("danmu loaded:", danmu_records.length);
|
||||
|
||||
// history danmaku sender
|
||||
setInterval(() => {
|
||||
if (video.paused) {
|
||||
return;
|
||||
}
|
||||
if (danmu_records.length == 0) {
|
||||
return;
|
||||
}
|
||||
// using live source
|
||||
if (isLive() && get_total() - video.currentTime <= 5) {
|
||||
return;
|
||||
}
|
||||
const cur = Math.floor(
|
||||
(video.currentTime + global_offset / 1000 + ts) * 1000,
|
||||
);
|
||||
console.log(new Date(cur).toString());
|
||||
let danmus = danmu_records.filter((v) => {
|
||||
return v.ts >= cur - 1000 && v.ts < cur;
|
||||
});
|
||||
danmus.forEach((v) => danmu_handler(v.content));
|
||||
}, 1000);
|
||||
|
||||
if (isLive()) {
|
||||
// add a account select
|
||||
const accountSelect = document.createElement("select");
|
||||
accountSelect.style.height = "30px";
|
||||
accountSelect.style.minWidth = "100px";
|
||||
accountSelect.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||
accountSelect.style.color = "white";
|
||||
accountSelect.style.border = "1px solid gray";
|
||||
accountSelect.style.padding = "0 10px";
|
||||
accountSelect.style.boxSizing = "border-box";
|
||||
accountSelect.style.fontSize = "1em";
|
||||
|
||||
// get accounts from tauri
|
||||
const account_info = (await invoke("get_accounts")) as AccountInfo;
|
||||
account_info.accounts.forEach((account) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = account.uid.toString();
|
||||
option.text = account.name;
|
||||
accountSelect.appendChild(option);
|
||||
});
|
||||
// add a danmaku send input
|
||||
const danmakuInput = document.createElement("input");
|
||||
danmakuInput.type = "text";
|
||||
danmakuInput.placeholder = "回车发送弹幕";
|
||||
danmakuInput.style.width = "50%";
|
||||
danmakuInput.style.height = "30px";
|
||||
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||
danmakuInput.style.color = "white";
|
||||
danmakuInput.style.border = "1px solid gray";
|
||||
danmakuInput.style.padding = "0 10px";
|
||||
danmakuInput.style.boxSizing = "border-box";
|
||||
danmakuInput.style.fontSize = "1em";
|
||||
danmakuInput.addEventListener("keydown", async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = danmakuInput.value;
|
||||
if (value) {
|
||||
// get account uid from select
|
||||
const uid = parseInt(accountSelect.value);
|
||||
await invoke("send_danmaku", {
|
||||
uid,
|
||||
roomId: room_id,
|
||||
ts,
|
||||
message: value,
|
||||
});
|
||||
danmakuInput.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
shakaSpacer.appendChild(accountSelect);
|
||||
shakaSpacer.appendChild(danmakuInput);
|
||||
|
||||
// listen to danmaku event
|
||||
listen("danmu:" + room_id, (event: { payload: DanmuEntry }) => {
|
||||
// add into records
|
||||
danmu_records.push(event.payload);
|
||||
// if not enabled or playback is not keep up with live, ignore the danmaku
|
||||
if (!danmu_enabled || get_total() - video.currentTime > 5) {
|
||||
return;
|
||||
}
|
||||
danmu_handler(event.payload.content);
|
||||
});
|
||||
}
|
||||
|
||||
// create a danmaku toggle button
|
||||
const danmakuToggle = document.createElement("button");
|
||||
danmakuToggle.innerText = "弹幕已开启";
|
||||
@@ -134,15 +232,6 @@
|
||||
: "rgba(255, 0, 0, 0.5)";
|
||||
});
|
||||
|
||||
// add to shaka-spacer
|
||||
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
|
||||
shakaSpacer.appendChild(accountSelect);
|
||||
shakaSpacer.appendChild(danmakuInput);
|
||||
shakaSpacer.appendChild(danmakuToggle);
|
||||
|
||||
// shaka-spacer should be flex-direction: column
|
||||
shakaSpacer.style.flexDirection = "column";
|
||||
|
||||
// create a area that overlay half top of the video, which shows danmakus floating from right to left
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.width = "100%";
|
||||
@@ -151,7 +240,7 @@
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.pointerEvents = "none";
|
||||
overlay.style.zIndex = "40";
|
||||
overlay.style.zIndex = "30";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.flexDirection = "column";
|
||||
@@ -162,12 +251,7 @@
|
||||
// Store the positions of the last few danmakus to avoid overlap
|
||||
const danmakuPositions = [];
|
||||
|
||||
// listen to danmaku event
|
||||
listen("danmu:" + room_id, (event: { payload: string }) => {
|
||||
console.log("danmu", event.payload);
|
||||
if (!danmu_enabled) {
|
||||
return;
|
||||
}
|
||||
function danmu_handler(content: string) {
|
||||
const danmaku = document.createElement("p");
|
||||
danmaku.style.position = "absolute";
|
||||
|
||||
@@ -199,7 +283,8 @@
|
||||
danmaku.style.margin = "0";
|
||||
danmaku.style.padding = "0";
|
||||
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);
|
||||
requestAnimationFrame(() => {
|
||||
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
||||
@@ -207,8 +292,41 @@
|
||||
danmaku.addEventListener("transitionend", () => {
|
||||
overlay.removeChild(danmaku);
|
||||
});
|
||||
}
|
||||
|
||||
shakaSpacer.appendChild(danmakuToggle);
|
||||
|
||||
// create a playback rate select to of shaka-spacer
|
||||
const playbackRateSelect = document.createElement("select");
|
||||
playbackRateSelect.style.height = "30px";
|
||||
playbackRateSelect.style.minWidth = "60px";
|
||||
playbackRateSelect.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
|
||||
playbackRateSelect.style.color = "white";
|
||||
playbackRateSelect.style.border = "1px solid gray";
|
||||
playbackRateSelect.style.padding = "0 10px";
|
||||
playbackRateSelect.style.boxSizing = "border-box";
|
||||
playbackRateSelect.style.fontSize = "1em";
|
||||
playbackRateSelect.style.right = "10px";
|
||||
playbackRateSelect.style.position = "absolute";
|
||||
playbackRateSelect.innerHTML = `
|
||||
<option value="0.5">0.5x</option>
|
||||
<option value="1">1x</option>
|
||||
<option value="1.5">1.5x</option>
|
||||
<option value="2">2x</option>
|
||||
<option value="5">5x</option>
|
||||
`;
|
||||
// default playback rate is 1
|
||||
playbackRateSelect.value = "1";
|
||||
playbackRateSelect.addEventListener("change", () => {
|
||||
const rate = parseFloat(playbackRateSelect.value);
|
||||
video.playbackRate = rate;
|
||||
});
|
||||
|
||||
shakaSpacer.appendChild(playbackRateSelect);
|
||||
|
||||
// shaka-spacer should be flex-direction: column
|
||||
shakaSpacer.style.flexDirection = "column";
|
||||
|
||||
function isLive() {
|
||||
return player.isLive();
|
||||
}
|
||||
@@ -250,11 +368,15 @@
|
||||
video.pause();
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
case "p":
|
||||
if (e.repeat) {
|
||||
break;
|
||||
}
|
||||
video.muted = !video.muted;
|
||||
// dispatch event
|
||||
dispatch("markerAdd", {
|
||||
offset: video.currentTime,
|
||||
realtime: ts + video.currentTime,
|
||||
});
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
video.currentTime -= 3;
|
||||
@@ -299,10 +421,42 @@
|
||||
}%, rgba(255, 255, 255, 0.4) ${
|
||||
first_point * 100
|
||||
}%, rgba(255, 255, 255, 0.2) ${first_point * 100}%)`;
|
||||
// render markers in shaka-ad-markers
|
||||
const adMarkers = document.querySelector(
|
||||
".shaka-ad-markers",
|
||||
) as HTMLElement;
|
||||
if (adMarkers) {
|
||||
// clean previous markers
|
||||
adMarkers.innerHTML = "";
|
||||
for (const marker of markers) {
|
||||
const markerElement = document.createElement("div");
|
||||
markerElement.style.position = "absolute";
|
||||
markerElement.style.width = "6px";
|
||||
markerElement.style.height = "7px";
|
||||
markerElement.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
|
||||
markerElement.style.left = `calc(${(marker.offset / total) * 100}% - 3px)`;
|
||||
markerElement.style.top = "-12px";
|
||||
// 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 rgba(0, 128, 255, 0.5)";
|
||||
triangle.style.position = "absolute";
|
||||
triangle.style.top = "7px";
|
||||
triangle.style.left = "0";
|
||||
markerElement.appendChild(triangle);
|
||||
adMarkers.appendChild(markerElement);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(updateSeekbar);
|
||||
}
|
||||
requestAnimationFrame(updateSeekbar);
|
||||
}
|
||||
|
||||
meta_parse();
|
||||
|
||||
// receive tauri emit
|
||||
document.addEventListener("shaka-ui-loaded", init);
|
||||
|
||||
@@ -333,10 +487,10 @@
|
||||
<p><kbd>]</kbd>设定选区结束</p>
|
||||
<p><kbd>q</kbd>跳转到选区开始</p>
|
||||
<p><kbd>e</kbd>跳转到选区结束</p>
|
||||
<p><kbd>Alt</kbd><kbd>←</kbd>前进</p>
|
||||
<p><kbd>Alt</kbd><kbd>→</kbd>后退</p>
|
||||
<p><kbd>←</kbd>前进</p>
|
||||
<p><kbd>→</kbd>后退</p>
|
||||
<p><kbd>c</kbd>清除选区</p>
|
||||
<p><kbd>m</kbd>静音</p>
|
||||
<p><kbd>p</kbd>创建标记</p>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -359,7 +513,7 @@
|
||||
}
|
||||
|
||||
#overlay {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -1,347 +1,337 @@
|
||||
<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";
|
||||
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: [],
|
||||
};
|
||||
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);
|
||||
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
|
||||
<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}
|
||||
> -->
|
||||
{/if}
|
||||
<DropdownItem
|
||||
on:click={() => {
|
||||
invoke("add_recorder", { roomId: Number(addRoom) }).catch(
|
||||
async (e) => {
|
||||
await message(
|
||||
"请检查房间号是否有效:" + e,
|
||||
"添加失败",
|
||||
);
|
||||
},
|
||||
);
|
||||
}}>确定</Button
|
||||
>
|
||||
<Button color="alternative">取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
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>
|
||||
|
||||
<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 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,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
alert(e);
|
||||
});
|
||||
}}>移除</Button
|
||||
>
|
||||
</ButtonGroup>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
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>
|
||||
@@ -96,3 +96,44 @@ export interface DiskInfo {
|
||||
total: number;
|
||||
free: number;
|
||||
}
|
||||
|
||||
export interface VideoType {
|
||||
id: number;
|
||||
parent: number;
|
||||
parent_name: string;
|
||||
name: string;
|
||||
description: string;
|
||||
desc: string;
|
||||
intro_original: string;
|
||||
intro_copy: string;
|
||||
notice: string;
|
||||
copy_right: number;
|
||||
show: boolean;
|
||||
rank: number;
|
||||
children: Children[];
|
||||
max_video_count: number;
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
export interface Children {
|
||||
id: number;
|
||||
parent: number;
|
||||
parent_name: string;
|
||||
name: string;
|
||||
description: string;
|
||||
desc: string;
|
||||
intro_original: string;
|
||||
intro_copy: string;
|
||||
notice: string;
|
||||
copy_right: number;
|
||||
show: boolean;
|
||||
rank: number;
|
||||
max_video_count: number;
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
offset: number;
|
||||
realtime: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const mobile =
|
||||
process.env.TAURI_PLATFORM === "ios";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
// @ts-ignore
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [
|
||||
svelte({
|
||||
|
||||