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

View File

@@ -1,9 +1,12 @@
pub mod bilibili;
pub mod danmu;
use async_std::{fs, stream::StreamExt};
use bilibili::{errors::BiliClientError, RoomInfo};
use bilibili::{BiliClient, UserInfo};
use bilibili::{BiliClient, BiliStream, StreamType, UserInfo};
use chrono::prelude::*;
use custom_error::custom_error;
use danmu::{DanmuEntry, DanmuStorage};
use dashmap::DashMap;
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
use ffmpeg_sidecar::{
command::FfmpegCommand,
@@ -11,22 +14,26 @@ use ffmpeg_sidecar::{
};
use futures::future::join_all;
use m3u8_rs::Playlist;
use rand::Rng;
use regex::Regex;
use tauri_plugin_notification::NotificationExt;
use std::sync::Arc;
use std::thread;
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Url};
use tauri_plugin_notification::NotificationExt;
use tokio::fs::OpenOptions;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::{Mutex, RwLock};
use crate::db::{AccountRow, Database, DatabaseError, RecordRow};
use crate::database::{account::AccountRow, record::RecordRow, Database, DatabaseError};
use crate::Config;
#[derive(Clone)]
pub struct TsEntry {
pub url: String,
pub offset: u64,
pub sequence: u64,
pub _length: f64,
pub length: f64,
pub size: u64,
}
@@ -45,34 +52,35 @@ pub struct BiliRecorder {
pub room_id: u64,
pub room_info: Arc<RwLock<RoomInfo>>,
pub user_info: Arc<RwLock<UserInfo>>,
pub m3u8_url: Arc<RwLock<String>>,
pub live_status: Arc<RwLock<bool>>,
pub last_sequence: Arc<RwLock<u64>>,
pub ts_length: Arc<RwLock<f64>>,
pub timestamp: Arc<RwLock<u64>>,
ts_entries: Arc<Mutex<Vec<TsEntry>>>,
last_update: Arc<RwLock<i64>>,
quit: Arc<Mutex<bool>>,
header: Arc<RwLock<Option<TsEntry>>>,
stream_type: Arc<RwLock<StreamType>>,
pub live_stream: Arc<RwLock<Option<BiliStream>>>,
cache_size: Arc<RwLock<u64>>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StreamType {
TS,
FMP4,
danmu_storage: Arc<RwLock<Option<DanmuStorage>>>,
m3u8_cache: DashMap<u64, String>,
}
custom_error! {pub RecorderError
NotStarted = "Room is offline",
IndexNotFound {url: String} = "Index not found: {url}",
ArchiveInUse {ts: u64} = "Can not delete current stream: {ts}",
EmptyCache = "Cache is empty",
M3u8ParseFailed = "Parse m3u8 content failed",
InvalidM3u8Url {url: String} = "Invalid m3u8 url: {url}",
M3u8ParseFailed {content: String } = "Parse m3u8 content failed: {content}",
NoStreamAvailable = "No available stream provided",
FreezedStream {stream: BiliStream} = "Stream is freezed: {stream}",
InvalidStream {stream: BiliStream} = "Invalid stream: {stream}",
SlowStream {stream: BiliStream} = "Stream is too slow: {stream}",
EmptyHeader = "Header url is empty",
InvalidTimestamp = "Header timestamp is invalid",
InvalidPlaylist = "Invalid m3u8 playlist",
InvalidDBOP {err: DatabaseError } = "Database error {err}",
ClientError {err: BiliClientError} = "BiliClient fetch failed {err}",
InvalidDBOP {err: DatabaseError } = "Database error: {err}",
ClientError {err: BiliClientError} = "BiliClient error: {err}",
ClipError {err: String} = "FFMPEG error: {err}",
IoError {err: std::io::Error} = "IO error: {err}",
}
impl From<DatabaseError> for RecorderError {
@@ -101,16 +109,14 @@ impl BiliRecorder {
let user_info = client
.get_user_info(webid, account, room_info.user_id)
.await?;
let mut m3u8_url = String::from("");
let mut live_status = false;
let mut stream_type = StreamType::FMP4;
let mut live_stream = None;
if room_info.live_status == 1 {
live_status = true;
if let Ok((index_url, stream_type_now)) =
client.get_play_url(account, room_info.room_id).await
{
m3u8_url = index_url;
stream_type = stream_type_now;
if let Ok(stream) = client.get_play_url(account, room_info.room_id).await {
live_stream = Some(stream);
} else {
log::error!("[{}]Room is online but fetching stream failed", room_id);
}
}
@@ -123,16 +129,18 @@ impl BiliRecorder {
room_id,
room_info: Arc::new(RwLock::new(room_info)),
user_info: Arc::new(RwLock::new(user_info)),
m3u8_url: Arc::new(RwLock::new(m3u8_url)),
live_status: Arc::new(RwLock::new(live_status)),
last_sequence: Arc::new(RwLock::new(0)),
ts_length: Arc::new(RwLock::new(0.0)),
ts_entries: Arc::new(Mutex::new(Vec::new())),
timestamp: Arc::new(RwLock::new(0)),
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
quit: Arc::new(Mutex::new(false)),
header: Arc::new(RwLock::new(None)),
stream_type: Arc::new(RwLock::new(stream_type)),
live_stream: Arc::new(RwLock::new(live_stream)),
cache_size: Arc::new(RwLock::new(0)),
danmu_storage: Arc::new(RwLock::new(None)),
m3u8_cache: DashMap::new(),
};
log::info!("Recorder for room {} created.", room_id);
Ok(recorder)
@@ -144,16 +152,19 @@ impl BiliRecorder {
self.ts_entries.lock().await.clear();
*self.header.write().await = None;
*self.timestamp.write().await = 0;
*self.last_update.write().await = Utc::now().timestamp();
*self.danmu_storage.write().await = None;
}
async fn check_status(&self) -> bool {
if let Ok(room_info) = self
match self
.client
.read()
.await
.get_room_info(&self.account, self.room_id)
.await
{
Ok(room_info) => {
*self.room_info.write().await = room_info.clone();
let live_status = room_info.live_status == 1;
@@ -165,40 +176,75 @@ impl BiliRecorder {
.notification()
.builder()
.title("BiliShadowReplay - 直播开始")
.body(format!("{} 开启了直播:{}",self.user_info.read().await.user_name, room_info.room_title)).show().unwrap();
.body(format!(
"{} 开启了直播:{}",
self.user_info.read().await.user_name,
room_info.room_title
))
.show()
.unwrap();
}
} else {
if self.config.read().await.live_end_notify {
} else if self.config.read().await.live_end_notify {
self.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 直播结束")
.body(format!("{} 的直播结束了",self.user_info.read().await.user_name)).show().unwrap();
}
.body(format!(
"{} 的直播结束了",
self.user_info.read().await.user_name
))
.show()
.unwrap();
}
}
// if stream is confirmed to be closed, live stream cache is cleaned.
// all request will go through fs
if live_status {
if let Ok((index_url, stream_type)) = self
let mut rng = rand::thread_rng();
// WHY: when program started, all stream is fetched nearly at the same time, so they will expire toggether,
// this might meet server rate limit. So we add a random offset to make request spread over time.
let offset = rng.gen_range(5..=120);
// no need to update stream as it's not expired yet
if self
.live_stream
.read()
.await
.as_ref()
.is_some_and(|s| s.expire - offset > Utc::now().timestamp())
{
return live_status;
}
log::info!(
"[{}]Stream is empty or nearly expired, updating",
self.room_id
);
match self
.client
.read()
.await
.get_play_url(&self.account, self.room_id)
.await
{
self.m3u8_url.write().await.replace_range(.., &index_url);
*self.stream_type.write().await = stream_type;
Ok(stream) => {
log::info!("[{}]Update stream: {:?}", self.room_id, stream);
*self.live_stream.write().await = Some(stream);
}
Err(e) => {
log::error!("[{}]Update stream failed: {}", self.room_id, e);
}
}
} else {
self.reset().await;
}
*self.live_status.write().await = live_status;
live_status
} else {
*self.live_status.write().await = true;
// may encouter internet issues, not sure whether the stream is closed
true
}
Err(e) => {
log::error!("[{}]Update room status failed: {}", self.room_id, e);
// may encouter internet issues, not sure whether the stream is closed or started, just remain
*self.live_status.read().await
}
}
}
@@ -210,15 +256,20 @@ impl BiliRecorder {
Ok(self.db.get_record(self.room_id, live_id).await?)
}
pub async fn delete_archive(&self, ts: u64) {
pub async fn delete_archive(&self, ts: u64) -> Result<(), RecorderError> {
if ts == *self.timestamp.read().await {
return Err(RecorderError::ArchiveInUse { ts });
}
if let Err(e) = self.db.remove_record(ts).await {
log::error!("remove archive failed: {}", e);
} else {
return Err(e.into());
}
let target_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
if fs::remove_dir_all(target_dir).await.is_err() {
log::error!("remove archive failed [{}]{}", self.room_id, ts);
}
if let Err(e) = fs::remove_dir_all(target_dir).await {
log::error!("remove archive failed [{}]{}: {}", self.room_id, ts, e);
return Err(RecorderError::IoError { err: e });
}
Ok(())
}
pub async fn run(&self) {
@@ -231,12 +282,15 @@ impl BiliRecorder {
// Live status is ok, start recording.
while !*self_clone.quit.lock().await {
if let Err(e) = self_clone.update_entries().await {
log::error!("update entries error: {}", e);
log::error!("[{}]Update entries error: {}", self_clone.room_id, e);
break;
}
thread::sleep(std::time::Duration::from_secs(1));
}
// go check status again
// go check status again after random 2-5 secs
let mut rng = rand::thread_rng();
let secs = rng.gen_range(2..=5);
thread::sleep(std::time::Duration::from_secs(secs));
continue;
}
// Every 10s check live status.
@@ -255,6 +309,10 @@ impl BiliRecorder {
});
}
pub async fn stop(&self) {
*self.quit.lock().await = true;
}
async fn danmu(&self) {
let (tx, rx) = mpsc::unbounded_channel();
let cookies = self.account.cookies.clone();
@@ -271,47 +329,92 @@ impl BiliRecorder {
mut rx: UnboundedReceiver<WsStreamMessageType>,
) -> Result<(), FelgensError> {
while let Some(msg) = rx.recv().await {
if *self.quit.lock().await {
break;
}
if let WsStreamMessageType::DanmuMsg(msg) = msg {
self.app_handle
.emit(&format!("danmu:{}", room), msg.msg.clone())
.emit(
&format!("danmu:{}", room),
DanmuEntry {
ts: msg.timestamp,
content: msg.msg.clone(),
},
)
.unwrap();
if *self.live_status.read().await {
// save danmu
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
storage.add_line(msg.timestamp, &msg.msg).await;
}
}
}
}
Ok(())
}
async fn get_playlist(&self) -> Result<Playlist, RecorderError> {
let url = self.m3u8_url.read().await.clone();
let mut index_content = self.client.read().await.get_index_content(&url).await?;
let stream = self.live_stream.read().await.clone();
if stream.is_none() {
return Err(RecorderError::NoStreamAvailable);
}
let stream = stream.unwrap();
match self
.client
.read()
.await
.get_index_content(&stream.index())
.await
{
Ok(index_content) => {
if index_content.is_empty() {
return Err(RecorderError::InvalidStream { stream });
}
if index_content.contains("Not Found") {
// 404 try another time after update
if self.check_status().await {
index_content = self.client.read().await.get_index_content(&url).await?;
} else {
return Err(RecorderError::NotStarted);
return Err(RecorderError::IndexNotFound {
url: stream.index(),
});
}
m3u8_rs::parse_playlist_res(index_content.as_bytes()).map_err(|_| {
RecorderError::M3u8ParseFailed {
content: index_content.clone(),
}
})
}
Err(e) => {
log::error!("Failed fetching index content from {}", stream.index());
Err(RecorderError::ClientError { err: e })
}
}
m3u8_rs::parse_playlist_res(index_content.as_bytes())
.map_err(|_| RecorderError::M3u8ParseFailed)
}
async fn get_header_url(&self) -> Result<String, RecorderError> {
let url = self.m3u8_url.read().await.clone();
let mut index_content = self.client.read().await.get_index_content(&url).await?;
if index_content.contains("Not Found") {
// 404 try another time after update
log::warn!("Index content not found: {}", index_content);
if self.check_status().await {
index_content = self.client.read().await.get_index_content(&url).await?;
} else {
return Err(RecorderError::NotStarted);
let stream = self.live_stream.read().await.clone();
if stream.is_none() {
return Err(RecorderError::NoStreamAvailable);
}
let stream = stream.unwrap();
let index_content = self
.client
.read()
.await
.get_index_content(&stream.index())
.await?;
if index_content.is_empty() {
return Err(RecorderError::InvalidStream { stream });
}
if index_content.contains("Not Found") {
return Err(RecorderError::IndexNotFound {
url: stream.index(),
});
}
if index_content.contains("BANDWIDTH") {
// this index content provides another m3u8 url
let new_url = index_content.lines().last().unwrap();
*self.m3u8_url.write().await = String::from(new_url);
return Box::pin(self.get_header_url()).await;
// // this index content provides another m3u8 url
// let new_url = index_content.lines().last().unwrap();
// *self.m3u8_url.write().await = String::from(new_url);
// return Box::pin(self.get_header_url()).await;
log::error!("BANDWIDTH index content: {}", index_content);
return Err(RecorderError::InvalidStream { stream });
}
let mut header_url = String::from("");
let re = Regex::new(r"h.*\.m4s").unwrap();
@@ -324,28 +427,6 @@ impl BiliRecorder {
Ok(header_url)
}
async fn ts_url(&self, ts_url: &String) -> Result<String, RecorderError> {
// Construct url for ts and fmp4 stream.
match *self.stream_type.read().await {
StreamType::TS => {
let url = self.m3u8_url.read().await.clone();
if let Some(pos) = url.rfind("index.m3u8") {
Ok(format!("{}{}", &url[..pos], ts_url))
} else {
Err(RecorderError::InvalidM3u8Url { url })
}
}
StreamType::FMP4 => {
let url = self.m3u8_url.read().await.clone();
if let Some(pos) = url.rfind("index.m3u8") {
Ok(format!("{}{}", &url[..pos], ts_url))
} else {
Err(RecorderError::InvalidM3u8Url { url })
}
}
}
}
async fn extract_timestamp(&self, header_url: &str) -> u64 {
log::debug!("[{}]Extract timestamp from {}", self.room_id, header_url);
let re = Regex::new(r"h(\d+).m4s").unwrap();
@@ -360,12 +441,21 @@ impl BiliRecorder {
}
async fn update_entries(&self) -> Result<(), RecorderError> {
let current_stream = self.live_stream.read().await.clone();
if current_stream.is_none() {
return Err(RecorderError::NoStreamAvailable);
}
let current_stream = current_stream.unwrap();
let parsed = self.get_playlist().await;
let mut timestamp = *self.timestamp.read().await;
let mut work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
let mut work_dir = format!(
"{}/{}/{}/",
self.config.read().await.cache,
self.room_id,
timestamp
);
// Check header if None
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
{
if self.header.read().await.is_none() && current_stream.format == StreamType::FMP4 {
// Get url from EXT-X-MAP
let header_url = self.get_header_url().await?;
if header_url.is_empty() {
@@ -384,7 +474,12 @@ impl BiliRecorder {
)
.await?;
// now work dir is confirmed
work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
work_dir = format!(
"{}/{}/{}/",
self.config.read().await.cache,
self.room_id,
timestamp
);
// if folder is exisited, need to load previous data into cache
if let Ok(meta) = fs::metadata(&work_dir).await {
if meta.is_dir() {
@@ -398,14 +493,18 @@ impl BiliRecorder {
// make sure work_dir is created
fs::create_dir_all(&work_dir).await.unwrap();
}
let full_header_url = self.ts_url(&header_url).await?;
// danmau file
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
*self.danmu_storage.write().await = DanmuStorage::new(&danmu_file_path).await;
let full_header_url = current_stream.ts_url(&header_url);
let file_name = header_url.split('/').last().unwrap();
let mut header = TsEntry {
url: full_header_url.clone(),
url: file_name.to_string(),
offset: 0,
sequence: 0,
_length: 0.0,
length: 0.0,
size: 0,
};
let file_name = header_url.split('/').last().unwrap();
// Download header
match self
.client
@@ -428,6 +527,7 @@ impl BiliRecorder {
match parsed {
Ok(Playlist::MasterPlaylist(pl)) => log::debug!("Master playlist:\n{:?}", pl),
Ok(Playlist::MediaPlaylist(pl)) => {
let mut new_segment_fetched = false;
let mut sequence = pl.media_sequence;
let mut handles = Vec::new();
for ts in pl.segments {
@@ -435,27 +535,57 @@ impl BiliRecorder {
sequence += 1;
continue;
}
let mut ts_entry = TsEntry {
url: ts.uri,
new_segment_fetched = true;
let mut offset_hex: String = "".into();
let mut seg_offset: u64 = 0;
for tag in ts.unknown_tags {
if tag.tag == "BILI-AUX" {
if let Some(rest) = tag.rest {
let parts: Vec<&str> = rest.split('|').collect();
if parts.is_empty() {
continue;
}
offset_hex = parts.first().unwrap().to_string();
seg_offset = u64::from_str_radix(&offset_hex, 16).unwrap();
}
break;
}
}
let ts_url = current_stream.ts_url(&ts.uri);
if Url::parse(&ts_url).is_err() {
log::error!("Ts url is invalid. ts_url={} original={}", ts_url, ts.uri);
continue;
}
// encode segment offset into filename
let mut entries = self.ts_entries.lock().await;
let file_name =
format!("{}-{}", &offset_hex, ts.uri.split('/').last().unwrap());
let mut ts_length = 1.0;
// calculate entry length using offset
// the default #EXTINF is 1.0, which is not accurate
if !entries.is_empty() {
// skip this entry as it is already in cache or stream changed
if seg_offset <= entries.last().unwrap().offset {
continue;
}
ts_length = (seg_offset - entries.last().unwrap().offset) as f64 / 1000.0;
}
let ts_entry = TsEntry {
url: file_name.clone(),
offset: seg_offset,
sequence,
_length: ts.duration as f64,
length: ts_length,
size: 0,
};
let client = self.client.clone();
let ts_url = self.ts_url(&ts_entry.url).await?;
ts_entry.url = ts_url.clone();
if ts_url.is_empty() {
continue;
}
let work_dir = work_dir.clone();
let cache_size_clone = self.cache_size.clone();
handles.push(tokio::task::spawn(async move {
let ts_url_clone = ts_url.clone();
let file_name = ts_url_clone.split('/').last().unwrap();
let file_name_clone = file_name.clone();
match client
.read()
.await
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name_clone))
.await
{
Ok(size) => {
@@ -466,7 +596,6 @@ impl BiliRecorder {
}
}
}));
let mut entries = self.ts_entries.lock().await;
entries.push(ts_entry);
*self.last_sequence.write().await = sequence;
let mut total_length = self.ts_length.write().await;
@@ -475,20 +604,49 @@ impl BiliRecorder {
}
join_all(handles).await.into_iter().for_each(|e| {
if let Err(e) = e {
log::error!("download ts failed: {:?}", e);
log::error!("Download ts failed: {:?}", e);
}
});
// currently we take every segement's length as 1.0s.
if new_segment_fetched {
*self.last_update.write().await = Utc::now().timestamp();
self.db
.update_record(
timestamp,
self.ts_entries.lock().await.len() as i64,
self.ts_entries
.lock()
.await
.iter()
.fold(0.0, |t, e| t + e.length) as i64,
*self.cache_size.read().await,
)
.await?;
} else {
// if index content is not changed for a long time, we should return a error to fetch a new stream
if *self.last_update.read().await < Utc::now().timestamp() - 10 {
log::error!("Stream content is not updating for 10s, maybe not started yet or not closed properly.");
return Err(RecorderError::FreezedStream {
stream: current_stream,
});
}
Err(_) => {
return Err(RecorderError::InvalidPlaylist);
}
// check the current stream is too slow or not
let entries = self.ts_entries.lock().await;
if !entries.is_empty() {
let last_entry = entries.last().unwrap();
let last_entry_time = (last_entry.offset + *self.timestamp.read().await) as i64;
if last_entry_time < Utc::now().timestamp() - 10 {
log::error!(
"Stream is too slow, last entry ts is at {}",
last_entry_time
);
return Err(RecorderError::SlowStream {
stream: current_stream,
});
}
}
}
Err(e) => {
return Err(e);
}
}
Ok(())
@@ -535,57 +693,40 @@ impl BiliRecorder {
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
log::info!("create archive clip for range [{}, {}]", x, y);
log::info!("Create archive clip for range [{}, {}]", x, y);
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
let entries = self.get_fs_entries(&work_dir).await;
if entries.is_empty() {
return Err(RecorderError::EmptyCache);
}
let mut file_list = String::new();
let mut file_list = Vec::new();
// header fist
file_list += &format!("{}/h{}.m4s", work_dir, ts);
file_list += "|";
file_list.push(format!("{}/h{}.m4s", work_dir, ts));
// add body entries
let mut offset = 0.0;
// seconds to ms
let begin = (x * 1000.0) as u64;
let end = (y * 1000.0) as u64;
let offset = entries.first().unwrap().offset;
if !entries.is_empty() {
for e in entries {
if offset < x {
offset += 1.0;
if e.offset - offset < begin {
continue;
}
file_list += &format!("{}/{}", work_dir, e.url);
file_list += "|";
if offset > y {
file_list.push(format!("{}/{}", work_dir, e.url));
if e.offset - offset > end {
break;
}
offset += 1.0;
}
}
std::fs::create_dir_all(output_path).expect("create clips folder failed");
let file_name = format!(
"{}/[{}]{}_{}_{:.1}.mp4",
output_path,
"[{}]{}_{}_{:.1}.mp4",
self.room_id,
ts,
Utc::now().format("%m%d%H%M%S"),
y - x
);
log::info!("{}", file_name);
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
FfmpegCommand::new()
.args(args.split(' '))
.output(file_name.clone())
.spawn()
.unwrap()
.iter()
.unwrap()
.for_each(|e| match e {
FfmpegEvent::Log(LogLevel::Error, e) => log::error!("Error: {}", e),
FfmpegEvent::Progress(p) => log::info!("Progress: {}", p.time),
_ => {}
});
Ok(file_name)
Self::generate_clip(&file_list, output_path, &file_name).await
}
pub async fn clip_live_range(
@@ -594,71 +735,93 @@ impl BiliRecorder {
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
log::info!("create live clip for range [{}, {}]", x, y);
log::info!("Create live clip for range [{}, {}]", x, y);
let mut to_combine = Vec::new();
let header_copy = self.header.read().await.clone();
let entry_copy = self.ts_entries.lock().await.clone();
if entry_copy.is_empty() {
return Err(RecorderError::EmptyCache);
}
let mut start = x;
let mut end = y;
if start > end {
std::mem::swap(&mut start, &mut end);
}
let mut offset = 0.0;
let begin = (x * 1000.0) as u64;
let end = (y * 1000.0) as u64;
let offset = entry_copy.first().unwrap().offset;
// TODO using binary search
for e in entry_copy.iter() {
if (offset as f64) < start {
offset += 1.0;
if e.offset - offset < begin {
continue;
}
to_combine.push(e);
if (offset as f64) >= end {
if e.offset - offset > end {
break;
}
offset += 1.0;
}
if *self.stream_type.read().await == StreamType::FMP4 {
if self
.live_stream
.read()
.await
.as_ref()
.is_some_and(|s| s.format == StreamType::FMP4)
{
// add header to vec
let header = header_copy.as_ref().unwrap();
to_combine.insert(0, header);
}
let mut file_list = String::new();
let mut file_list = Vec::new();
let timestamp = *self.timestamp.read().await;
for e in to_combine {
let file_name = e.url.split('/').last().unwrap();
let file_path = format!(
"{}/{}/{}/{}",
self.config.read().await.cache, self.room_id, timestamp, file_name
);
file_list += &file_path;
file_list += "|";
}
let title = self.room_info.read().await.room_title.clone();
let title: String = title.chars().take(5).collect();
std::fs::create_dir_all(output_path).expect("create clips folder failed");
let file_name = format!(
"{}/[{}]{}_{}_{:.1}.mp4",
output_path,
self.config.read().await.cache,
self.room_id,
title,
Utc::now().format("%m%d%H%M%S"),
end - start
timestamp,
file_name
);
log::info!("{}", file_name);
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
FfmpegCommand::new()
.args(args.split(' '))
.output(file_name.clone())
.spawn()
.unwrap()
.iter()
.unwrap()
.for_each(|e| match e {
FfmpegEvent::Log(LogLevel::Error, e) => log::error!("Error: {}", e),
FfmpegEvent::Progress(p) => log::info!("Progress: {}", p.time),
_ => {}
file_list.push(file_path);
}
let file_name = format!(
"[{}]{}_{}_{:.1}.mp4",
self.room_id,
self.timestamp.read().await,
Utc::now().format("%m%d%H%M%S"),
y - x
);
Self::generate_clip(&file_list, output_path, &file_name).await
}
async fn generate_clip(
file_list: &Vec<String>,
output_path: &str,
file_name: &str,
) -> Result<String, RecorderError> {
std::fs::create_dir_all(output_path).expect("create clips folder failed");
let file_name = format!("{}/{}", output_path, file_name,);
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&file_name)
.await;
if file.is_err() {
return Err(RecorderError::ClipError {
err: file.err().unwrap().to_string(),
});
}
let mut file = file.unwrap();
// write file content in file_list into file
for f in file_list {
let seg_file = OpenOptions::new().read(true).open(f).await;
if seg_file.is_err() {
log::error!("Reading {} failed, skip", f);
continue;
}
let mut seg_file = seg_file.unwrap();
let mut buffer = Vec::new();
seg_file.read_to_end(&mut buffer).await.unwrap();
file.write_all(&buffer).await.unwrap();
}
file.flush().await.unwrap();
Ok(file_name)
}
@@ -672,6 +835,9 @@ impl BiliRecorder {
}
async fn generate_archive_m3u8(&self, timestamp: u64) -> String {
if self.m3u8_cache.contains_key(&timestamp) {
return self.m3u8_cache.get(&timestamp).unwrap().clone();
}
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:6\n";
m3u8_content += "#EXT-X-TARGETDURATION:1\n";
@@ -681,22 +847,35 @@ impl BiliRecorder {
let header_url = format!("/{}/{}/h{}.m4s", self.room_id, timestamp, timestamp);
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
// add entries from read_dir
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, timestamp);
let work_dir = format!(
"{}/{}/{}",
self.config.read().await.cache,
self.room_id,
timestamp
);
let entries = self.get_fs_entries(&work_dir).await;
if entries.is_empty() {
return m3u8_content;
}
let mut last_sequence = entries.first().unwrap().sequence;
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
for e in entries {
let current_seq = e.sequence;
if current_seq - last_sequence > 1 {
m3u8_content += "#EXT-X-DISCONTINUITY\n"
}
last_sequence = current_seq;
m3u8_content += "#EXTINF:1,\n";
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
let ts = timestamp + e.offset / 1000;
let date_str = Utc.timestamp_opt(ts as i64, 0).unwrap().to_rfc3339();
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
m3u8_content += &format!("#EXTINF:{:.2},\n", e.length);
m3u8_content += &format!("/{}/{}/{}\n", self.room_id, timestamp, e.url);
last_sequence = current_seq;
}
m3u8_content += "#EXT-X-ENDLIST";
// cache this
self.m3u8_cache.insert(timestamp, m3u8_content.clone());
m3u8_content
}
@@ -721,18 +900,54 @@ impl BiliRecorder {
if !etype.is_file() {
continue;
}
if let Some(file_ext) = e.path().extension() {
let file_ext = file_ext.to_str().unwrap().to_string();
// need to exclude other files, such as danmu file
if file_ext != "m4s" {
continue;
}
} else {
continue;
}
let file_name = e.file_name().to_str().unwrap().to_string();
if file_name.starts_with("h") {
continue;
}
let meta_info: &str = file_name.split('.').next().unwrap();
let infos: Vec<&str> = meta_info.split('-').collect();
let offset: u64;
let sequence: u64;
// BREAKCHANGE do not support legacy files that not named with offset
if infos.len() == 1 {
continue;
} else {
if let Ok(parsed_offset) = u64::from_str_radix(infos.first().unwrap(), 16) {
offset = parsed_offset;
} else {
continue;
}
sequence = infos.get(1).unwrap().parse().unwrap();
}
ret.push(TsEntry {
url: file_name.clone(),
sequence: file_name.split('.').next().unwrap().parse().unwrap(),
_length: 1.0,
offset,
sequence,
length: 1.0,
size: e.metadata().await.unwrap().len(),
});
}
ret.sort_by(|a, b| a.sequence.cmp(&b.sequence));
if ret.is_empty() {
return ret;
}
let mut last_offset = ret.first().unwrap().offset;
for (i, entry) in ret.iter_mut().enumerate() {
if i == 0 {
continue;
}
entry.length = (entry.offset - last_offset) as f64 / 1000.0;
last_offset = entry.offset;
}
ret
}
@@ -757,16 +972,23 @@ impl BiliRecorder {
}
let entries = self.ts_entries.lock().await.clone();
if entries.is_empty() {
m3u8_content += "#EXT-X-OFFSET:0\n";
return m3u8_content;
}
let timestamp = *self.timestamp.read().await;
let mut last_sequence = entries.first().unwrap().sequence;
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
for entry in entries.iter() {
if entry.sequence - last_sequence > 1 {
// discontinuity happens
m3u8_content += "#EXT-X-DISCONTINUITY\n"
}
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
let ts = timestamp + entry.offset / 1000;
let date_str = Utc.timestamp_opt(ts as i64, 0).unwrap().to_rfc3339();
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
m3u8_content += &format!("#EXTINF:{:.2},\n", entry.length,);
last_sequence = entry.sequence;
m3u8_content += "#EXTINF:1,\n";
let file_name = entry.url.split('/').last().unwrap();
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
m3u8_content += &format!("{}\n", local_url);
@@ -777,4 +999,30 @@ impl BiliRecorder {
}
m3u8_content
}
pub async fn get_danmu_record(&self, ts: u64) -> Vec<DanmuEntry> {
if ts == *self.timestamp.read().await {
// just return current cache content
match self.danmu_storage.read().await.as_ref() {
Some(storage) => storage.get_entries().await,
None => Vec::new(),
}
} else {
// load disk cache
let cache_file_path = format!(
"{}/{}/{}/{}",
self.config.read().await.cache,
self.room_id,
ts,
"danmu.txt"
);
log::info!("loading danmu cache from {}", cache_file_path);
let storage = DanmuStorage::new(&cache_file_path).await;
if storage.is_none() {
return Vec::new();
}
let storage = storage.unwrap();
storage.get_entries().await
}
}
}

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() {
if let response::Data::RoomPlayInfo(data) = res.data {
if let Some(stream) = 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 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

@@ -80,5 +80,6 @@
.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,7 +181,8 @@
}
loading = true;
let new_cover = generateCover();
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成中`);
update_title(`切片生成中`);
try {
let new_video = (await invoke("clip_range", {
roomId: room_id,
cover: new_cover,
@@ -167,7 +190,7 @@
x: start,
y: end,
})) as VideoItem;
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成成功`);
update_title(`切片生成成功`);
console.log("video file generatd:", video);
await get_video_list();
video_selected = new_video.id;
@@ -176,13 +199,16 @@
});
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"
<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);
}}><UserAddSolid class="w-8 h-8" /></Button
}}>扫码添加</ListgroupItem
>
</div>
<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,6 +115,41 @@
`;
shakaBottomControls.appendChild(selfSeekbar);
// 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";
@@ -114,7 +198,21 @@
}
});
let danmu_enabled = true;
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

@@ -145,9 +145,7 @@
<Badge color="dark">未直播</Badge>
{/if}
</TableBodyCell>
<TableBodyCell
>{format_time(room.total_length)}</TableBodyCell
>
<TableBodyCell>{format_time(room.total_length)}</TableBodyCell>
<TableBodyCell>
<Button size="sm" color="dark"
>操作<ChevronDownOutline
@@ -164,13 +162,13 @@
});
}}>打开直播流</DropdownItem
>
<DropdownItem
<!-- <DropdownItem
on:click={() => {
quickClipRoom = room.room_id;
quickClipSelected = 30;
quickClipModal = true;
}}>快速切片</DropdownItem
>
> -->
{/if}
<DropdownItem
on:click={() => {
@@ -207,9 +205,7 @@
<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 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
确定要移除这个直播间吗?
</h3>
<Button
@@ -277,10 +273,7 @@
on:click={() => {
invoke("add_recorder", { roomId: Number(addRoom) }).catch(
async (e) => {
await message(
"请检查房间号是否有效:" + e,
"添加失败",
);
await message("请检查房间号是否有效:" + e, "添加失败");
},
);
}}>确定</Button
@@ -301,13 +294,9 @@
<TableBody tableBodyClass="divide-y">
{#each archives as archive}
<TableBodyRow>
<TableBodyCell
>{format_ts(archive.created_at)}</TableBodyCell
>
<TableBodyCell>{format_ts(archive.created_at)}</TableBodyCell>
<TableBodyCell>{archive.title}</TableBodyCell>
<TableBodyCell
>{format_duration(archive.length)}</TableBodyCell
>
<TableBodyCell>{format_duration(archive.length)}</TableBodyCell>
<TableBodyCell>
<span>{format_size(archive.size)}</span>
</TableBodyCell>
@@ -327,13 +316,14 @@
invoke("delete_archive", {
roomId: archiveRoom.room_id,
ts: archive.live_id,
}).then(async () => {
archives = await invoke(
"get_archives",
{
})
.then(async () => {
archives = await invoke("get_archives", {
roomId: archiveRoom.room_id,
},
);
});
})
.catch((e) => {
alert(e);
});
}}>移除</Button
>

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