Compare commits

...

68 Commits

Author SHA1 Message Date
Xinrea
bc458647a3 release: bump version to 1.3.1
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 23:16:42 +08:00
Xinrea
6c5080394a refactor: address clippy warnings
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 16:24:57 +08:00
Xinrea
30d45ca2c3 refactor(database): modularize database implementation
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 15:58:46 +08:00
Xinrea
614aa3184f fix(player): use 'p' as marker shortcut to avoid conflicts (close #29)
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 15:36:28 +08:00
Xinrea
cacd28bd87 release: bump version to 1.3.0
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 00:54:26 +08:00
Xinrea
a3dfe86a04 doc: update livewindow description in README
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 00:51:23 +08:00
Xinrea
79c65cab63 Merge pull request #28 from Xinrea/feat/timepoint-mark
时间点标记功能
2024-11-26 00:42:47 +08:00
Xinrea
52237b9385 feat(livewindow): add timepoint mark support
By the way, livewindow style change to dark theme.

Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-26 00:38:21 +08:00
Xinrea
b8d22e92ff feat(livewindow): add collapse button for post panel
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-25 14:56:08 +08:00
Xinrea
faac0e29b5 fix(recorder): prevent deletion of current stream archive
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-25 02:11:46 +08:00
Xinrea
899afac910 feat(livewindow): support selection of video post area (close #20)
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-25 02:10:53 +08:00
Xinrea
7b2cbfefcc fix(recorder): safely stop recorder when removing room
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-25 01:57:20 +08:00
Xinrea
793e532240 feat(bilibili): add video type list api
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-24 21:47:10 +08:00
Xinrea
68c7bd251e refactor(bilibili): move response struct into mod response
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-24 20:39:21 +08:00
Xinrea
656f82df43 release: bump version to 1.2.1
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-23 12:49:04 +08:00
Xinrea
1571358f13 fix(recorder): live is mistakenly marked as online when client error (close #27)
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-23 12:45:57 +08:00
Xinrea
b6326cf36a fix(recorder): add error to handle slow stream
BiliBili stream's index content maybe lagging behind real-time content,
potentially caused by cdn issue. We need to switch into a new stream
in this situation.

Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-23 12:35:28 +08:00
Xinrea
0b7818a9d3 fix(recorder): handle freezed stream
Sometimes the stream index content may stop updating after re-pushing stream,
but it is still available. In this situation, we need to fetch a new stream.

Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-23 01:05:26 +08:00
Xinrea
b479d6086b feat(recorder): update current cached stream length with acurrate value
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-23 00:48:16 +08:00
Xinrea
6d06491ddc fix(recorder): manually construct clip instead of using ffmpeg (close #25)
Too many segments will make ffmpeg command spawn with long argument, which might
be exceeded the limit.
Now we manually copy and write segments into one file to generate a clip.
FFMPEG is still remained but not used for now, in the future clip encoding might
need this.

Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-22 22:12:32 +08:00
Xinrea
e15c6d44a1 feat(recorder, bilibili): add more info into error messages
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-22 02:10:36 +08:00
Xinrea
8ab656a097 feat(recorder): add random delay to avoid api limit
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-21 15:57:02 +08:00
Xinrea
cb8642b9a9 feat(main): add message for room deletion
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-19 16:39:54 +08:00
Xinrea
1761d398ee feat(recorder): enhance error message for archive deletion
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-11-19 13:41:54 +08:00
Xinrea
eb33fd57a8 feat(bilibili, recorder): introduce BiliStream to handle stream expiration and extra 2024-11-18 14:33:49 +08:00
Xinrea
317b0b373a fix(recorder): handle error when creating danmu storage 2024-11-18 14:24:51 +08:00
Xinrea
563c8d0085 fix(bilibili): correct the potential issue with retrieving the wrong stream 2024-11-14 18:47:55 +08:00
Xinrea
80ced70267 feat(bilibili): add timeout for requests 2024-11-14 18:46:17 +08:00
Xinrea
3a89b43435 feat(recorder, bilibili): enhance error messages for improved debugging 2024-11-14 18:44:59 +08:00
Xinrea
d117095a5f feat(player): persist live room volume setting 2024-11-12 01:46:09 +08:00
Xinrea
d78c1bf861 fix(recorder): panic when offset not matching sequence 2024-11-09 01:01:31 +08:00
Xinrea
20da8034a1 chore: add git-cliff to generate Changelog
git-cliff configuration is provided. To generate Changelog:
git cliff -l
2024-11-07 12:48:37 +08:00
Xinrea
0d053a3462 release: bump version to 1.2.0 2024-11-07 02:08:07 +08:00
Xinrea
280e540f4f fix(recorder): using "-" instead of "|" to separate offset and sequence
When using "|" in file name, ffmpeg concat is not able to work. So now
we change it to "-".
Here is an example for new format of segment filename:
18b7af7-89947993.m4s
2024-11-07 01:39:40 +08:00
Xinrea
824cfd23ed fix(recorder): using global offset to find clip segments range
The clip range drift problem is finally solved by calculate
date-time for every segment. With date-time, we can ignore
gaps in m3u8 segments. close #5
2024-11-07 01:16:20 +08:00
Xinrea
695728df2e feat(recorder): add DATE-TIME tag for segments 2024-11-06 20:04:49 +08:00
Xinrea
24deca75d2 fix(recorder_manager): handle cors preflight request for hls server 2024-11-06 20:04:13 +08:00
Xinrea
8a1184f161 fix(recorder): clip using accurate segment length 2024-11-06 12:04:02 +08:00
Xinrea
d61096d1b1 refactor(recorder): calculate segment length in entry-creation 2024-11-06 02:14:42 +08:00
Xinrea
3b9d1be002 fix: use accurate segment length to prevent video time drift 2024-11-04 20:00:38 +08:00
Xinrea
13262f8f10 feat: add history danmu replay (close #16) 2024-11-03 21:24:54 +08:00
Xinrea
9f05fc4954 release: bump version to 1.1.0 2024-10-30 21:32:49 +08:00
Xinrea
3fce06ef63 chore: code format 2024-10-30 21:30:40 +08:00
Xinrea
3d13f69e5c feat: log output to file 2024-10-30 21:28:43 +08:00
Xinrea
deb19c6223 feat: change clip encoding to copy for speed 2024-10-30 21:01:58 +08:00
Xinrea
7466127832 feat: add account using cookie str (close #19) 2024-10-30 20:55:35 +08:00
Xinrea
af982c5fe0 fix: project url on about page 2024-10-30 18:38:25 +08:00
Xinrea
b03f0150d8 release: bump version to 1.0.6 2024-10-26 12:43:07 +08:00
Xinrea
d61ddafb44 fix: remove window effects
Tauri currently does not provide an API to retrieve the active window effects, making it impossible to adjust the window style based on that information. Therefore, we have removed the window effects to maintain UI consistency.

close #18
2024-10-26 12:42:41 +08:00
Xinrea
fd89a197a5 release: bump version to 1.0.5 2024-10-25 21:09:36 +08:00
Xinrea
31fa29ee62 fix: shortkey description 2024-10-25 21:08:32 +08:00
Xinrea
c7e28b2ad6 doc: update readme 2024-10-25 21:06:20 +08:00
Xinrea
bbc1343079 chore: fix some clippy warning 2024-10-25 20:19:24 +08:00
Xinrea
c7d4fb270b feat: add text-shadow for danmaku 2024-10-25 01:03:34 +08:00
Xinrea
fcccdee105 release: bump version to 1.0.4 2024-10-24 01:04:36 +08:00
Xinrea
887072f6c7 chore: remove quick-clip for now 2024-10-24 01:04:02 +08:00
Xinrea
1932edba21 feat: remove live-window fullscreen button on windows 2024-10-24 01:01:43 +08:00
Xinrea
0c15415822 feat: delete related cache folder when removing recorder 2024-10-24 01:01:04 +08:00
Xinrea
b8dc0870b5 feat: adjust cover-text style 2024-10-22 02:00:24 +08:00
Xinrea
9d0ad2ae45 feat: hide danmaku when playback is not keeping up with live stream 2024-10-22 01:43:44 +08:00
Xinrea
7278b9f48c fix: remove unused overflow-button 2024-10-22 01:39:22 +08:00
Xinrea
1aee95492a fix: only enable danmaku in live 2024-10-22 01:34:48 +08:00
Xinrea
0cff889f4b release: bump version to 1.0.3 2024-10-21 04:58:03 +08:00
Xinrea
9cd05362ac chore: update dependency ffmpeg-sidecar to 1.2.0 2024-10-21 04:57:24 +08:00
Xinrea
269eccc7ef feat: add playback rate select 2024-10-21 03:43:38 +08:00
Xinrea
aafd02090b feat: live_window title using formatted timestamp to show date 2024-10-21 03:21:53 +08:00
Xinrea
e0e43dbfa4 fix: prevent danmaku overflow from showing scrollbar 2024-10-21 02:39:49 +08:00
Xinrea
37c358a48b fix: video preview modal background 2024-10-21 02:27:07 +08:00
46 changed files with 3228 additions and 1488 deletions

View File

@@ -1,12 +1,64 @@
# Bilibili ShadowReplay
# BiliBili ShadowReplay
![icon](doc/header.png)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/xinrea/bili-shadowreplay/main.yml)
![GitHub Release](https://img.shields.io/github/v/release/xinrea/bili-shadowreplay)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/xinrea/bili-shadowreplay/total)
BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
> [!NOTE]
> 由于软件在快速开发中,截图说明可能有变动,仅供参考
![rooms](doc/summary.png)
## 总览
![rooms](doc/summary.png)
显示直播缓存的占用以及缓存所在磁盘的使用情况。
## 直播间管理
![clip](doc/rooms.png)
显示当前缓存的直播间列表,在添加前需要在账号页面添加至少一个账号(主账号)用于直播流以及用户信息的获取。
操作菜单包含打开直播流、查看历史记录以及删除等操作。其中历史记录以列表形式展示,可以进行回放以及删除。
![archives](doc/archives.png)
无论是正在进行的直播还是历史录播,都可在预览窗口进行回放,同时也可以进行切片编辑以及投稿。关于预览窗口的相关说明请见 [预览窗口](#预览窗口)。
## 消息管理
![messages](doc/messages.png)
执行的各种操作都会留下消息记录,方便查看过去进行的操作。
## 账号管理
![accounts](doc/accounts.png)
程序需要至少一个账号用于直播流以及用户信息的获取,可以在此页面添加账号。目前添加账号仅支持 B 站手机 App 扫码添加。
你可以添加多个账号,但只有一个账号会被标记为主账号,主账号用于直播流的获取。所有账号都可在切片投稿或是观看直播流发送弹幕时自由选择,详情见 [预览窗口](#预览窗口)。
## 预览窗口
![livewindow](doc/livewindow.png)
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片、记录时间点以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
通过预览窗口的快捷键操作,可以快速选择时间区间,进行切片生成以及投稿。
无论是弹幕发送还是投稿,均可自由选择账号,只要在账号管理中添加了该账号。
## 设置
![settings](doc/settings.png)
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
> [!WARNING]
> 程序仍在开发中, Rlease 中提供的下载版本为历史遗留版本, 不保证能够正常使用
![rooms](doc/rooms.png)
## 介绍
Bilibili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
![clip](doc/clip.png)
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。缓存切换开始和结束均会在消息管理中有记录。

78
cliff.toml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

BIN
doc/archives.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

BIN
doc/header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
doc/livewindow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

BIN
doc/messages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

BIN
doc/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

BIN
doc/summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "bili-shadowreplay",
"private": true,
"version": "1.0.2",
"version": "1.3.1",
"type": "module",
"scripts": {
"dev": "vite",

310
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -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

View File

@@ -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
View File

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

View File

@@ -0,0 +1,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?,
)
}
}

View File

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

View File

@@ -0,0 +1,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(())
}
}

View 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?)
}
}

View 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)
}
}

View File

@@ -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)
}
}

View File

@@ -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");

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

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

View File

@@ -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()
}
}

View File

@@ -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>(

View File

@@ -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"
}
]
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View File

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

View File

@@ -1,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;

View File

@@ -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
View File

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

View File

@@ -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;
}

View File

@@ -8,6 +8,7 @@ const mobile =
process.env.TAURI_PLATFORM === "ios";
// https://vitejs.dev/config/
// @ts-ignore
export default defineConfig(async () => ({
plugins: [
svelte({