Compare commits

...

93 Commits

Author SHA1 Message Date
Xinrea
d73e95d2e5 feat: cache migration 2025-03-14 11:31:04 +08:00
Xinrea
770338a68a fix: auto-close for donate modal 2025-03-13 11:36:17 +08:00
Xinrea
f58dafbde8 fix: video frame seek on windows 2025-03-13 01:30:35 +08:00
Xinrea
004712e851 feat: fix bilibili clip 2025-03-13 01:22:37 +08:00
Xinrea
4e53ed2cf8 fix: work dir path on windows 2025-03-13 01:12:33 +08:00
Xinrea
92ccad6253 fix: enable window decoration on windows 2025-03-13 01:11:29 +08:00
Xinrea
74c5e9bb09 Merge pull request #36 from Xinrea/feat/new-ui
v2.0.0 版本功能更新
2025-03-13 00:29:37 +08:00
Xinrea
2724d6b4d3 doc: update 2025-03-13 00:15:19 +08:00
Xinrea
212e144422 refactor: adjust player init order 2025-03-13 00:04:56 +08:00
Xinrea
205a1b82e7 bump version to 2.0.0 2025-03-12 23:51:51 +08:00
Xinrea
44b4604581 feat: add checks before deleting archive 2025-03-12 23:48:21 +08:00
Xinrea
3d3454b5a4 feat: advanced cover editor and donate page 2025-03-12 23:36:54 +08:00
Xinrea
67f1b04b67 feat: douyin support 2025-03-12 19:39:32 +08:00
Xinrea
fd7d299e55 refactor: decouple recorder 2025-03-10 19:21:59 +08:00
Xinrea
ada492f3f0 refactor: new ui 2025-03-10 19:21:43 +08:00
Xinrea
8a4e4fd32b fix: using first account when primary missing
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-23 00:52:57 +08:00
Xinrea
86ced2a217 release: bump version to 1.4.2
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:22:34 +08:00
Xinrea
c62251dfe9 fix(livewindow): adjust titlebar index to avoid blocking buttons
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:21:42 +08:00
Xinrea
8bf0f5d36e fix(recorder_manager): unable to remove recorder that has no cache folder created yet
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:10:02 +08:00
Xinrea
a4b6567947 feat(main, recorder): remove unused ffmpeg dependency
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:10:02 +08:00
Xinrea
6c5c628bbf fix(recorder): reconnect danmu ws after disconnection
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-18 02:10:02 +08:00
Xinrea
1d6593340d fix(account,room): duplicated avatar when adding/removing items
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-03 01:04:08 +08:00
Xinrea
0d992d205f release: bump version to 1.4.1
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-02 00:39:08 +08:00
Xinrea
f82e79efd4 Merge pull request #32 from Xinrea/feat/quick-stream-switch
feat(player): add shortcut button to navigate to other live rooms
2024-12-02 00:36:49 +08:00
Xinrea
5cdb6b6f75 feat(player): add shortcut button to navigate to other live rooms (close #31)
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-02 00:36:22 +08:00
Xinrea
7316a022be feat(player): enable player lowlatency mode
Signed-off-by: Xinrea <xinreas@outlook.com>
2024-12-02 00:32:31 +08:00
Xinrea
dbd8a29b73 feat(recorder): dynamic entry update interval
Sometimes entry update cost a lot of time (caused by timeout or something),
which makes the actually interval much larger than 1s.

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

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

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

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

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

View File

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

View File

@@ -1,12 +1,68 @@
# 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)
> [!WARNING] > [!WARNING]
> 程序仍在开发中, Rlease 中提供的下载版本为历史遗留版本, 不保证能够正常使用 > v2.0.0 版本为重大更新,将不兼容 v1.x 版本的数据。
![rooms](doc/rooms.png) BiliBili ShadowReplay 是一个缓存直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
## 介绍 目前仅支持 B 站和抖音平台的直播。
Bilibili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。 ![rooms](doc/summary.png)
![clip](doc/clip.png) ## 总览
![rooms](doc/summary.png)
## 直播间管理
![clip](doc/rooms.png)
显示当前缓存的直播间列表,在添加前需要在账号页面添加至少一个账号(主账号)用于直播流以及用户信息的获取。
操作菜单包含打开直播流、查看历史记录以及删除等操作。其中历史记录以列表形式展示,可以进行回放以及删除。
![archives](doc/archives.png)
无论是正在进行的直播还是历史录播,都可在预览窗口进行回放,同时也可以进行切片编辑以及投稿。关于预览窗口的相关说明请见 [预览窗口](#预览窗口)。
## 账号管理
![accounts](doc/accounts.png)
程序需要至少一个账号用于直播流以及用户信息的获取,可以在此页面添加账号。
你可以添加多个账号,但只有一个账号会被标记为主账号,主账号用于直播流的获取。所有账号都可在切片投稿或是观看直播流发送弹幕时自由选择,详情见 [预览窗口](#预览窗口)。
抖音账号目前仅支持手动 Cookie 添加,且账号仅用于获取直播信息和直播流。
## 预览窗口
![livewindow](doc/livewindow.png)
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片、记录时间点以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
通过预览窗口的快捷键操作,可以快速选择时间区间,进行切片生成以及投稿。
无论是弹幕发送还是投稿,均可自由选择账号,只要在账号管理中添加了该账号。
进度条上方会显示弹幕频率图,可以直观地看到弹幕的分布情况;右侧的弹幕统计过滤器可以用于过滤弹幕,只显示含有指定文字的弹幕的统计情况。
## 封面编辑
![cover](doc/coveredit.png)
在预览窗口中,生成切片后可以进行封面编辑,包括关键帧的选择、文字的添加和拖动等。
## 设置
![settings](doc/settings.png)
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
> [!WARNING]
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。

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: 555 KiB

BIN
doc/archives.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

BIN
doc/coveredit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 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: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 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: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

BIN
doc/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

BIN
doc/summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

View File

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

View File

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

BIN
public/imgs/donate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

BIN
public/imgs/douyin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

26
src-tauri/Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@@ -316,9 +316,9 @@ checksum = "7a40729d2133846d9ed0ea60a8b9541bccddab49cd30f0715a1da672fe9a2524"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.68" version = "0.1.87"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -407,6 +407,8 @@ name = "bili-shadowreplay"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"async-std", "async-std",
"async-trait",
"base64 0.21.0",
"chrono", "chrono",
"custom_error", "custom_error",
"dashmap", "dashmap",
@@ -417,9 +419,11 @@ dependencies = [
"log", "log",
"m3u8-rs", "m3u8-rs",
"md5", "md5",
"mime_guess",
"notify-rust", "notify-rust",
"pct-str", "pct-str",
"platform-dirs", "platform-dirs",
"rand 0.8.5",
"regex", "regex",
"reqwest 0.11.15", "reqwest 0.11.15",
"serde", "serde",
@@ -2818,6 +2822,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -5985,6 +5999,12 @@ dependencies = [
"unic-common", "unic-common",
] ]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.12" version = "0.3.12"

View File

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

View File

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

View File

@@ -1 +1 @@
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default"]}} {"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default"]}}

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,780 +1,74 @@
pub mod bilibili; pub mod bilibili;
use async_std::{fs, stream::StreamExt}; pub mod douyin;
use bilibili::{errors::BiliClientError, RoomInfo}; pub mod danmu;
use bilibili::{BiliClient, UserInfo}; pub mod errors;
use chrono::prelude::*;
use custom_error::custom_error;
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
use ffmpeg_sidecar::{
command::FfmpegCommand,
event::{FfmpegEvent, LogLevel},
};
use futures::future::join_all;
use m3u8_rs::Playlist;
use regex::Regex;
use tauri_plugin_notification::NotificationExt;
use std::sync::Arc;
use std::thread;
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::{Mutex, RwLock};
use crate::db::{AccountRow, Database, DatabaseError, RecordRow}; mod entry;
use crate::Config;
#[derive(Clone)] use async_trait::async_trait;
pub struct TsEntry { use danmu::DanmuEntry;
pub url: String,
pub sequence: u64, #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub _length: f64, pub enum PlatformType {
pub size: u64, BiliBili,
Douyin,
Huya,
Youtube,
} }
/// A recorder for BiliBili live streams impl PlatformType {
/// pub fn as_str(&self) -> &'static str {
/// This recorder fetches, caches and serves TS entries, currently supporting only StreamType::FMP4. match self {
/// As high-quality streams are accessible only to logged-in users, the use of a BiliClient, which manages cookies, is required. PlatformType::BiliBili => "bilibili",
// TODO implement StreamType::TS PlatformType::Douyin => "douyin",
#[derive(Clone)] PlatformType::Huya => "huya",
pub struct BiliRecorder { PlatformType::Youtube => "youtube",
app_handle: AppHandle, }
client: Arc<RwLock<BiliClient>>, }
db: Arc<Database>,
account: AccountRow, pub fn from_str(s: &str) -> Option<Self> {
config: Arc<RwLock<Config>>, match s {
"bilibili" => Some(PlatformType::BiliBili),
"douyin" => Some(PlatformType::Douyin),
"huya" => Some(PlatformType::Huya),
"youtube" => Some(PlatformType::Youtube),
_ => None,
}
}
}
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct RecorderInfo {
pub room_id: u64, pub room_id: u64,
pub room_info: Arc<RwLock<RoomInfo>>, pub room_info: RoomInfo,
pub user_info: Arc<RwLock<UserInfo>>, pub user_info: UserInfo,
pub m3u8_url: Arc<RwLock<String>>, pub total_length: f64,
pub live_status: Arc<RwLock<bool>>, pub current_live_id: String,
pub last_sequence: Arc<RwLock<u64>>, pub live_status: bool,
pub ts_length: Arc<RwLock<f64>>, pub platform: String,
pub timestamp: Arc<RwLock<u64>>,
ts_entries: Arc<Mutex<Vec<TsEntry>>>,
quit: Arc<Mutex<bool>>,
header: Arc<RwLock<Option<TsEntry>>>,
stream_type: Arc<RwLock<StreamType>>,
cache_size: Arc<RwLock<u64>>,
} }
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub enum StreamType { pub struct RoomInfo {
TS, pub room_id: u64,
FMP4, pub room_title: String,
pub room_cover: String,
} }
custom_error! {pub RecorderError #[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
NotStarted = "Room is offline", pub struct UserInfo {
EmptyCache = "Cache is empty", pub user_id: String,
M3u8ParseFailed = "Parse m3u8 content failed", pub user_name: String,
InvalidM3u8Url {url: String} = "Invalid m3u8 url: {url}", pub user_avatar: String,
EmptyHeader = "Header url is empty",
InvalidTimestamp = "Header timestamp is invalid",
InvalidPlaylist = "Invalid m3u8 playlist",
InvalidDBOP {err: DatabaseError } = "Database error {err}",
ClientError {err: BiliClientError} = "BiliClient fetch failed {err}",
} }
impl From<DatabaseError> for RecorderError { #[async_trait]
fn from(value: DatabaseError) -> Self { pub trait Recorder: Send + Sync + 'static {
RecorderError::InvalidDBOP { err: value } async fn run(&self);
} async fn stop(&self);
} async fn clip_range(&self, live_id: &str, x: f64, y: f64, output_path: &str) -> Result<String, errors::RecorderError>;
async fn m3u8_content(&self, live_id: &str) -> String;
impl From<BiliClientError> for RecorderError { async fn info(&self) -> RecorderInfo;
fn from(value: BiliClientError) -> Self { async fn comments(&self, live_id: &str) -> Result<Vec<DanmuEntry>, errors::RecorderError>;
RecorderError::ClientError { err: value } async fn is_recording(&self, live_id: &str) -> bool;
}
}
impl BiliRecorder {
pub async fn new(
app_handle: AppHandle,
webid: &str,
db: &Arc<Database>,
room_id: u64,
account: &AccountRow,
config: Arc<RwLock<Config>>,
) -> Result<Self, RecorderError> {
let client = BiliClient::new()?;
let room_info = client.get_room_info(account, room_id).await?;
let user_info = client
.get_user_info(webid, account, room_info.user_id)
.await?;
let mut m3u8_url = String::from("");
let mut live_status = false;
let mut stream_type = StreamType::FMP4;
if room_info.live_status == 1 {
live_status = true;
if let Ok((index_url, stream_type_now)) =
client.get_play_url(account, room_info.room_id).await
{
m3u8_url = index_url;
stream_type = stream_type_now;
}
}
let recorder = Self {
app_handle,
client: Arc::new(RwLock::new(client)),
db: db.clone(),
account: account.clone(),
config,
room_id,
room_info: Arc::new(RwLock::new(room_info)),
user_info: Arc::new(RwLock::new(user_info)),
m3u8_url: Arc::new(RwLock::new(m3u8_url)),
live_status: Arc::new(RwLock::new(live_status)),
last_sequence: Arc::new(RwLock::new(0)),
ts_length: Arc::new(RwLock::new(0.0)),
ts_entries: Arc::new(Mutex::new(Vec::new())),
timestamp: Arc::new(RwLock::new(0)),
quit: Arc::new(Mutex::new(false)),
header: Arc::new(RwLock::new(None)),
stream_type: Arc::new(RwLock::new(stream_type)),
cache_size: Arc::new(RwLock::new(0)),
};
log::info!("Recorder for room {} created.", room_id);
Ok(recorder)
}
pub async fn reset(&self) {
*self.ts_length.write().await = 0.0;
*self.last_sequence.write().await = 0;
self.ts_entries.lock().await.clear();
*self.header.write().await = None;
*self.timestamp.write().await = 0;
}
async fn check_status(&self) -> bool {
if let Ok(room_info) = self
.client
.read()
.await
.get_room_info(&self.account, self.room_id)
.await
{
*self.room_info.write().await = room_info.clone();
let live_status = room_info.live_status == 1;
// handle live notification
if *self.live_status.read().await != live_status {
if live_status {
if self.config.read().await.live_start_notify {
self.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 直播开始")
.body(format!("{} 开启了直播:{}",self.user_info.read().await.user_name, room_info.room_title)).show().unwrap();
}
} else {
if self.config.read().await.live_end_notify {
self.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 直播结束")
.body(format!("{} 的直播结束了",self.user_info.read().await.user_name)).show().unwrap();
}
}
}
// if stream is confirmed to be closed, live stream cache is cleaned.
// all request will go through fs
if live_status {
if let Ok((index_url, stream_type)) = self
.client
.read()
.await
.get_play_url(&self.account, self.room_id)
.await
{
self.m3u8_url.write().await.replace_range(.., &index_url);
*self.stream_type.write().await = stream_type;
}
} else {
self.reset().await;
}
*self.live_status.write().await = live_status;
live_status
} else {
*self.live_status.write().await = true;
// may encouter internet issues, not sure whether the stream is closed
true
}
}
pub async fn get_archives(&self) -> Result<Vec<RecordRow>, RecorderError> {
Ok(self.db.get_records(self.room_id).await?)
}
pub async fn get_archive(&self, live_id: u64) -> Result<RecordRow, RecorderError> {
Ok(self.db.get_record(self.room_id, live_id).await?)
}
pub async fn delete_archive(&self, ts: u64) {
if let Err(e) = self.db.remove_record(ts).await {
log::error!("remove archive failed: {}", e);
} else {
let target_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
if fs::remove_dir_all(target_dir).await.is_err() {
log::error!("remove archive failed [{}]{}", self.room_id, ts);
}
}
}
pub async fn run(&self) {
let self_clone = self.clone();
thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
while !*self_clone.quit.lock().await {
if self_clone.check_status().await {
// Live status is ok, start recording.
while !*self_clone.quit.lock().await {
if let Err(e) = self_clone.update_entries().await {
log::error!("update entries error: {}", e);
break;
}
thread::sleep(std::time::Duration::from_secs(1));
}
// go check status again
continue;
}
// Every 10s check live status.
thread::sleep(std::time::Duration::from_secs(10));
}
log::info!("recording thread {} quit.", self_clone.room_id);
});
});
// Thread for danmaku
let self_clone = self.clone();
thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
self_clone.danmu().await;
});
});
}
async fn danmu(&self) {
let (tx, rx) = mpsc::unbounded_channel();
let cookies = self.account.cookies.clone();
let uid: u64 = self.account.uid;
let ws = ws_socket_object(tx, uid, self.room_id, cookies.as_str());
if let Err(e) = tokio::select! {v = ws => v, v = self.recv(self.room_id,rx) => v} {
log::debug!("{}", e);
}
}
async fn recv(
&self,
room: u64,
mut rx: UnboundedReceiver<WsStreamMessageType>,
) -> Result<(), FelgensError> {
while let Some(msg) = rx.recv().await {
if let WsStreamMessageType::DanmuMsg(msg) = msg {
self.app_handle
.emit(&format!("danmu:{}", room), msg.msg.clone())
.unwrap();
}
}
Ok(())
}
async fn get_playlist(&self) -> Result<Playlist, RecorderError> {
let url = self.m3u8_url.read().await.clone();
let mut index_content = self.client.read().await.get_index_content(&url).await?;
if index_content.contains("Not Found") {
// 404 try another time after update
if self.check_status().await {
index_content = self.client.read().await.get_index_content(&url).await?;
} else {
return Err(RecorderError::NotStarted);
}
}
m3u8_rs::parse_playlist_res(index_content.as_bytes())
.map_err(|_| RecorderError::M3u8ParseFailed)
}
async fn get_header_url(&self) -> Result<String, RecorderError> {
let url = self.m3u8_url.read().await.clone();
let mut index_content = self.client.read().await.get_index_content(&url).await?;
if index_content.contains("Not Found") {
// 404 try another time after update
log::warn!("Index content not found: {}", index_content);
if self.check_status().await {
index_content = self.client.read().await.get_index_content(&url).await?;
} else {
return Err(RecorderError::NotStarted);
}
}
if index_content.contains("BANDWIDTH") {
// this index content provides another m3u8 url
let new_url = index_content.lines().last().unwrap();
*self.m3u8_url.write().await = String::from(new_url);
return Box::pin(self.get_header_url()).await;
}
let mut header_url = String::from("");
let re = Regex::new(r"h.*\.m4s").unwrap();
if let Some(captures) = re.captures(&index_content) {
header_url = captures.get(0).unwrap().as_str().to_string();
}
if header_url.is_empty() {
log::warn!("Parse header url failed: {}", index_content);
}
Ok(header_url)
}
async fn ts_url(&self, ts_url: &String) -> Result<String, RecorderError> {
// Construct url for ts and fmp4 stream.
match *self.stream_type.read().await {
StreamType::TS => {
let url = self.m3u8_url.read().await.clone();
if let Some(pos) = url.rfind("index.m3u8") {
Ok(format!("{}{}", &url[..pos], ts_url))
} else {
Err(RecorderError::InvalidM3u8Url { url })
}
}
StreamType::FMP4 => {
let url = self.m3u8_url.read().await.clone();
if let Some(pos) = url.rfind("index.m3u8") {
Ok(format!("{}{}", &url[..pos], ts_url))
} else {
Err(RecorderError::InvalidM3u8Url { url })
}
}
}
}
async fn extract_timestamp(&self, header_url: &str) -> u64 {
log::debug!("[{}]Extract timestamp from {}", self.room_id, header_url);
let re = Regex::new(r"h(\d+).m4s").unwrap();
if let Some(cap) = re.captures(header_url) {
let ts = cap.get(1).unwrap().as_str().parse().unwrap();
*self.timestamp.write().await = ts;
ts
} else {
log::error!("Extract timestamp failed: {}", header_url);
0
}
}
async fn update_entries(&self) -> Result<(), RecorderError> {
let parsed = self.get_playlist().await;
let mut timestamp = *self.timestamp.read().await;
let mut work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
// Check header if None
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
{
// Get url from EXT-X-MAP
let header_url = self.get_header_url().await?;
if header_url.is_empty() {
return Err(RecorderError::EmptyHeader);
}
timestamp = self.extract_timestamp(&header_url).await;
if timestamp == 0 {
log::error!("[{}]Parse timestamp failed: {}", self.room_id, header_url);
return Err(RecorderError::InvalidTimestamp);
}
self.db
.add_record(
timestamp,
self.room_id,
&self.room_info.read().await.room_title,
)
.await?;
// now work dir is confirmed
work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
// if folder is exisited, need to load previous data into cache
if let Ok(meta) = fs::metadata(&work_dir).await {
if meta.is_dir() {
log::warn!("Live {} is already cached. Try to restore", timestamp);
self.restore(&work_dir).await;
} else {
// make sure work_dir is created
fs::create_dir_all(&work_dir).await.unwrap();
}
} else {
// make sure work_dir is created
fs::create_dir_all(&work_dir).await.unwrap();
}
let full_header_url = self.ts_url(&header_url).await?;
let mut header = TsEntry {
url: full_header_url.clone(),
sequence: 0,
_length: 0.0,
size: 0,
};
let file_name = header_url.split('/').last().unwrap();
// Download header
match self
.client
.read()
.await
.download_ts(&full_header_url, &format!("{}/{}", work_dir, file_name))
.await
{
Ok(size) => {
header.size = size;
*self.header.write().await = Some(header);
// add size into cache_size
*self.cache_size.write().await += size;
}
Err(e) => {
log::error!("Download header failed: {}", e);
}
}
}
match parsed {
Ok(Playlist::MasterPlaylist(pl)) => log::debug!("Master playlist:\n{:?}", pl),
Ok(Playlist::MediaPlaylist(pl)) => {
let mut sequence = pl.media_sequence;
let mut handles = Vec::new();
for ts in pl.segments {
if sequence <= *self.last_sequence.read().await {
sequence += 1;
continue;
}
let mut ts_entry = TsEntry {
url: ts.uri,
sequence,
_length: ts.duration as f64,
size: 0,
};
let client = self.client.clone();
let ts_url = self.ts_url(&ts_entry.url).await?;
ts_entry.url = ts_url.clone();
if ts_url.is_empty() {
continue;
}
let work_dir = work_dir.clone();
let cache_size_clone = self.cache_size.clone();
handles.push(tokio::task::spawn(async move {
let ts_url_clone = ts_url.clone();
let file_name = ts_url_clone.split('/').last().unwrap();
match client
.read()
.await
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
.await
{
Ok(size) => {
*cache_size_clone.write().await += size;
}
Err(e) => {
log::error!("Download ts failed: {}", e);
}
}
}));
let mut entries = self.ts_entries.lock().await;
entries.push(ts_entry);
*self.last_sequence.write().await = sequence;
let mut total_length = self.ts_length.write().await;
*total_length += ts.duration as f64;
sequence += 1;
}
join_all(handles).await.into_iter().for_each(|e| {
if let Err(e) = e {
log::error!("download ts failed: {:?}", e);
}
});
// currently we take every segement's length as 1.0s.
self.db
.update_record(
timestamp,
self.ts_entries.lock().await.len() as i64,
*self.cache_size.read().await,
)
.await?;
}
Err(_) => {
return Err(RecorderError::InvalidPlaylist);
}
}
Ok(())
}
async fn restore(&self, work_dir: &str) {
// by the way, header will be set after restore, so we don't need to restore it.
let entries = self.get_fs_entries(work_dir).await;
if entries.is_empty() {
return;
}
self.ts_entries.lock().await.extend_from_slice(&entries);
*self.ts_length.write().await = entries.len() as f64;
*self.cache_size.write().await = entries.iter().map(|e| e.size).sum();
*self.last_sequence.write().await = entries.last().unwrap().sequence;
log::info!("Restore {} entries from local file", entries.len());
}
pub async fn clip(&self, ts: u64, d: f64, output_path: &str) -> Result<String, RecorderError> {
let total_length = *self.ts_length.read().await;
self.clip_range(ts, total_length - d, total_length, output_path)
.await
}
/// x and y are relative to first sequence
pub async fn clip_range(
&self,
ts: u64,
x: f64,
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
if *self.timestamp.read().await == ts {
self.clip_live_range(x, y, output_path).await
} else {
self.clip_archive_range(ts, x, y, output_path).await
}
}
pub async fn clip_archive_range(
&self,
ts: u64,
x: f64,
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
log::info!("create archive clip for range [{}, {}]", x, y);
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
let entries = self.get_fs_entries(&work_dir).await;
if entries.is_empty() {
return Err(RecorderError::EmptyCache);
}
let mut file_list = String::new();
// header fist
file_list += &format!("{}/h{}.m4s", work_dir, ts);
file_list += "|";
// add body entries
let mut offset = 0.0;
if !entries.is_empty() {
for e in entries {
if offset < x {
offset += 1.0;
continue;
}
file_list += &format!("{}/{}", work_dir, e.url);
file_list += "|";
if offset > y {
break;
}
offset += 1.0;
}
}
std::fs::create_dir_all(output_path).expect("create clips folder failed");
let file_name = format!(
"{}/[{}]{}_{}_{:.1}.mp4",
output_path,
self.room_id,
ts,
Utc::now().format("%m%d%H%M%S"),
y - x
);
log::info!("{}", file_name);
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
FfmpegCommand::new()
.args(args.split(' '))
.output(file_name.clone())
.spawn()
.unwrap()
.iter()
.unwrap()
.for_each(|e| match e {
FfmpegEvent::Log(LogLevel::Error, e) => log::error!("Error: {}", e),
FfmpegEvent::Progress(p) => log::info!("Progress: {}", p.time),
_ => {}
});
Ok(file_name)
}
pub async fn clip_live_range(
&self,
x: f64,
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
log::info!("create live clip for range [{}, {}]", x, y);
let mut to_combine = Vec::new();
let header_copy = self.header.read().await.clone();
let entry_copy = self.ts_entries.lock().await.clone();
if entry_copy.is_empty() {
return Err(RecorderError::EmptyCache);
}
let mut start = x;
let mut end = y;
if start > end {
std::mem::swap(&mut start, &mut end);
}
let mut offset = 0.0;
for e in entry_copy.iter() {
if (offset as f64) < start {
offset += 1.0;
continue;
}
to_combine.push(e);
if (offset as f64) >= end {
break;
}
offset += 1.0;
}
if *self.stream_type.read().await == StreamType::FMP4 {
// add header to vec
let header = header_copy.as_ref().unwrap();
to_combine.insert(0, header);
}
let mut file_list = String::new();
let timestamp = *self.timestamp.read().await;
for e in to_combine {
let file_name = e.url.split('/').last().unwrap();
let file_path = format!(
"{}/{}/{}/{}",
self.config.read().await.cache, self.room_id, timestamp, file_name
);
file_list += &file_path;
file_list += "|";
}
let title = self.room_info.read().await.room_title.clone();
let title: String = title.chars().take(5).collect();
std::fs::create_dir_all(output_path).expect("create clips folder failed");
let file_name = format!(
"{}/[{}]{}_{}_{:.1}.mp4",
output_path,
self.room_id,
title,
Utc::now().format("%m%d%H%M%S"),
end - start
);
log::info!("{}", file_name);
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
FfmpegCommand::new()
.args(args.split(' '))
.output(file_name.clone())
.spawn()
.unwrap()
.iter()
.unwrap()
.for_each(|e| match e {
FfmpegEvent::Log(LogLevel::Error, e) => log::error!("Error: {}", e),
FfmpegEvent::Progress(p) => log::info!("Progress: {}", p.time),
_ => {}
});
Ok(file_name)
}
/// timestamp is the id of live stream
pub async fn generate_m3u8(&self, timestamp: u64) -> String {
if *self.timestamp.read().await == timestamp {
self.generate_live_m3u8().await
} else {
self.generate_archive_m3u8(timestamp).await
}
}
async fn generate_archive_m3u8(&self, timestamp: u64) -> String {
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:6\n";
m3u8_content += "#EXT-X-TARGETDURATION:1\n";
m3u8_content += "#EXT-X-PLAYLIST-TYPE:VOD\n";
// add header, FMP4 need this
// TODO handle StreamType::TS
let header_url = format!("/{}/{}/h{}.m4s", self.room_id, timestamp, timestamp);
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
// add entries from read_dir
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, timestamp);
let entries = self.get_fs_entries(&work_dir).await;
if entries.is_empty() {
return m3u8_content;
}
let mut last_sequence = entries.first().unwrap().sequence;
for e in entries {
let current_seq = e.sequence;
if current_seq - last_sequence > 1 {
m3u8_content += "#EXT-X-DISCONTINUITY\n"
}
last_sequence = current_seq;
m3u8_content += "#EXTINF:1,\n";
m3u8_content += &format!("/{}/{}/{}\n", self.room_id, timestamp, e.url);
}
m3u8_content += "#EXT-X-ENDLIST";
m3u8_content
}
/// Fetch HLS segments from local cached file, header is excluded
async fn get_fs_entries(&self, path: &str) -> Vec<TsEntry> {
let mut ret = Vec::new();
let direntry = fs::read_dir(path).await;
if direntry.is_err() {
return ret;
}
let mut direntry = direntry.unwrap();
while let Some(e) = direntry.next().await {
if e.is_err() {
continue;
}
let e = e.unwrap();
let etype = e.file_type().await;
if etype.is_err() {
continue;
}
let etype = etype.unwrap();
if !etype.is_file() {
continue;
}
let file_name = e.file_name().to_str().unwrap().to_string();
if file_name.starts_with("h") {
continue;
}
ret.push(TsEntry {
url: file_name.clone(),
sequence: file_name.split('.').next().unwrap().parse().unwrap(),
_length: 1.0,
size: e.metadata().await.unwrap().len(),
});
}
ret.sort_by(|a, b| a.sequence.cmp(&b.sequence));
ret
}
/// if fetching live/last stream m3u8, all entries are cached in memory, so it will be much faster than read_dir
async fn generate_live_m3u8(&self) -> String {
let live_status = *self.live_status.read().await;
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:6\n";
m3u8_content += "#EXT-X-TARGETDURATION:1\n";
// if stream is closed, switch to VOD
if live_status {
m3u8_content += "#EXT-X-PLAYLIST-TYPE:EVENT\n";
} else {
m3u8_content += "#EXT-X-PLAYLIST-TYPE:VOD\n";
}
let timestamp = *self.timestamp.read().await;
// initial segment for fmp4, info from self.header
if let Some(header) = self.header.read().await.as_ref() {
let file_name = header.url.split('/').last().unwrap();
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", local_url);
}
let entries = self.ts_entries.lock().await.clone();
if entries.is_empty() {
return m3u8_content;
}
let mut last_sequence = entries.first().unwrap().sequence;
for entry in entries.iter() {
if entry.sequence - last_sequence > 1 {
// discontinuity happens
m3u8_content += "#EXT-X-DISCONTINUITY\n"
}
last_sequence = entry.sequence;
m3u8_content += "#EXTINF:1,\n";
let file_name = entry.url.split('/').last().unwrap();
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
m3u8_content += &format!("{}\n", local_url);
}
// let player know stream is closed
if !live_status {
m3u8_content += "#EXT-X-ENDLIST";
}
m3u8_content
}
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -8,8 +8,8 @@ custom_error! {pub BiliClientError
InvalidUrl = "Invalid url", InvalidUrl = "Invalid url",
InvalidFormat = "Invalid stream format", InvalidFormat = "Invalid stream format",
EmptyCache = "Empty cache", EmptyCache = "Empty cache",
ClientError{err: reqwest::Error} = "Client error", ClientError{err: reqwest::Error} = "Client error: {err}",
IOError{err: std::io::Error} = "IO error", IOError{err: std::io::Error} = "IO error: {err}",
} }
impl From<reqwest::Error> for BiliClientError { impl From<reqwest::Error> for BiliClientError {

View File

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

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

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

View File

@@ -0,0 +1,123 @@
use base64::Engine;
use reqwest::{Client, Error as ReqwestError};
use m3u8_rs::{Playlist, MediaPlaylist};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use crate::database::account::AccountRow;
use super::response::DouyinRoomInfoResponse;
use std::fmt;
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
#[derive(Debug)]
pub enum DouyinClientError {
Network(ReqwestError),
Io(std::io::Error),
Playlist(String),
}
impl fmt::Display for DouyinClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(e) => write!(f, "Network error: {}", e),
Self::Io(e) => write!(f, "IO error: {}", e),
Self::Playlist(e) => write!(f, "Playlist error: {}", e),
}
}
}
impl From<ReqwestError> for DouyinClientError {
fn from(err: ReqwestError) -> Self {
DouyinClientError::Network(err)
}
}
impl From<std::io::Error> for DouyinClientError {
fn from(err: std::io::Error) -> Self {
DouyinClientError::Io(err)
}
}
#[derive(Clone)]
pub struct DouyinClient {
client: Client,
cookies: String,
}
impl DouyinClient {
pub fn new(account: &AccountRow) -> Self {
let client = Client::builder()
.user_agent(USER_AGENT)
.build()
.unwrap();
Self { client, cookies: account.cookies.clone() }
}
pub async fn get_room_info(&self, room_id: u64) -> Result<DouyinRoomInfoResponse, DouyinClientError> {
let url = format!(
"https://live.douyin.com/webcast/room/web/enter/?aid=6383&app_name=douyin_web&live_id=1&device_platform=web&language=zh-CN&enter_from=web_live&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Chrome&browser_version=122.0.0.0&web_rid={}",
room_id
);
let resp = self.client.get(&url)
.header("Referer", "https://live.douyin.com/")
.header("User-Agent", USER_AGENT)
.header("Cookie", self.cookies.clone())
.send()
.await?
.json::<DouyinRoomInfoResponse>()
.await?;
Ok(resp)
}
pub async fn get_cover_base64(&self, url: &str) -> Result<String, DouyinClientError> {
log::info!("get_cover_base64: {}", url);
let response = self.client.get(url).send().await?;
let bytes = response.bytes().await?;
let base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let mime_type = mime_guess::from_path(url).first_or_octet_stream().to_string();
Ok(format!("data:{};base64,{}", mime_type, base64))
}
pub async fn get_m3u8_content(&self, url: &str) -> Result<(MediaPlaylist, String), DouyinClientError> {
let content = self.client.get(url)
.send()
.await?
.text()
.await?;
// m3u8 content: #EXTM3U
// #EXT-X-VERSION:3
// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000
// http://7167739a741646b4651b6949b2f3eb8e.livehwc3.cn/pull-hls-l26.douyincdn.com/third/stream-693342996808860134_or4.m3u8?sub_m3u8=true&user_session_id=16090eb45ab8a2f042f7c46563936187&major_anchor_level=common&edge_slice=true&expire=67d944ec&sign=47b95cc6e8de20d82f3d404412fa8406
if content.contains("BANDWIDTH") {
let new_url = content.lines().last().unwrap();
return Box::pin(self.get_m3u8_content(new_url)).await;
}
match m3u8_rs::parse_playlist_res(content.as_bytes()) {
Ok(Playlist::MasterPlaylist(_)) => {
Err(DouyinClientError::Playlist("Unexpected master playlist".to_string()))
}
Ok(Playlist::MediaPlaylist(pl)) => Ok((pl, url.to_string())),
Err(e) => Err(DouyinClientError::Playlist(e.to_string())),
}
}
pub async fn download_ts(&self, url: &str, path: &str) -> Result<u64, DouyinClientError> {
let response = self.client.get(url)
.send()
.await?;
if response.status() != reqwest::StatusCode::OK {
return Err(DouyinClientError::Network(response.error_for_status().unwrap_err()));
}
let content = response.bytes().await?;
let mut file = File::create(path).await?;
file.write_all(&content).await?;
Ok(content.len() as u64)
}
}

View File

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

View File

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

View File

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

View File

View File

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

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

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

View File

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

View File

@@ -8,12 +8,9 @@
"title": "BiliBili ShadowReplay", "title": "BiliBili ShadowReplay",
"width": 1300, "width": 1300,
"height": 600, "height": 600,
"transparent": true, "transparent": false,
"decorations": false, "decorations": true,
"theme": "Light", "theme": "Light"
"windowEffects": {
"effects": ["tabbed", "mica"]
}
} }
] ]
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,16 +1,76 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import type { AccountInfo, AccountItem } from "./db"; import type { AccountInfo } from "./db";
import type { Marker, RecorderList, RecorderInfo } from "./interface";
export let port; import { createEventDispatcher } from "svelte";
export let room_id; import { GridOutline, SortHorizontalOutline } from "flowbite-svelte-icons";
export let ts; const dispatch = createEventDispatcher();
interface DanmuEntry {
ts: number;
content: string;
}
export let port: number;
export let platform: string;
export let room_id: number;
export let live_id: string;
export let start = 0; export let start = 0;
export let end = 0; export let end = 0;
export let markers: Marker[] = [];
export function seek(offset: number) {
video.currentTime = offset;
}
let video: HTMLVideoElement;
let show_detail = false; let show_detail = false;
let show_list = false;
let global_offset = 0;
let recorders: RecorderInfo[] = [];
// TODO get custom tag from shaka player instead of manual parsing
async function meta_parse() {
fetch(
`http://127.0.0.1:${port}/${platform}/${room_id}/${live_id}/playlist.m3u8`
)
.then((response) => response.text())
.then((m3u8Content) => {
const offsetRegex = /#EXT-X-OFFSET:(\d+)/;
const match = m3u8Content.match(offsetRegex);
if (match && match[1]) {
global_offset = parseInt(match[1], 10);
} else {
console.warn("No #EXT-X-OFFSET found");
}
})
.catch((error) => {
console.error("Error fetching M3U8 file:", error);
});
}
async function update_stream_list() {
recorders = (
(await invoke("get_recorder_list")) as RecorderList
).recorders.filter((r) => r.live_status && r.room_id != room_id);
}
function go_to(platform: string, room_id: number, live_id: string) {
const url = `${window.location.origin}${window.location.pathname}?port=${port}&platform=${platform}&room_id=${room_id}&live_id=${live_id}`;
window.location.href = url;
}
async function init() { async function init() {
const video = document.getElementById("video") as HTMLVideoElement; await meta_parse();
update_stream_list();
setInterval(async () => {
await update_stream_list();
}, 5 * 1000);
video = document.getElementById("video") as HTMLVideoElement;
const ui = video["ui"]; const ui = video["ui"];
const controls = ui.getControls(); const controls = ui.getControls();
const player = controls.getPlayer(); const player = controls.getPlayer();
@@ -26,9 +86,24 @@
// Attach player and UI to the window to make it easy to access in the JS console. // Attach player and UI to the window to make it easy to access in the JS console.
(window as any).player = player; (window as any).player = player;
(window as any).ui = ui; (window as any).ui = ui;
player.configure({
streaming: {
lowLatencyMode: true,
},
});
player.addEventListener("ended", async () => {
// prevent endless reload
setTimeout(location.reload, 3 * 1000);
});
player.addEventListener("manifestloaded", (event) => {
console.log("Manifest loaded:", event);
});
try { try {
await player.load( await player.load(
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8` `http://127.0.0.1:${port}/${platform}/${room_id}/${live_id}/playlist.m3u8`
); );
// This runs if the asynchronous load is successful. // This runs if the asynchronous load is successful.
console.log("The video has now been loaded!"); console.log("The video has now been loaded!");
@@ -39,8 +114,17 @@
location.reload(); location.reload();
} }
} }
player.addEventListener("ended", async () => {
location.reload(); // init video volume from localStorage
let localVolume = localStorage.getItem(`volume:${room_id}`);
if (localVolume != undefined) {
console.log("Load local volume", localVolume);
video.volume = parseFloat(localVolume);
}
video.addEventListener("volumechange", (event) => {
localStorage.setItem(`volume:${room_id}`, video.volume.toString());
console.log("Update volume to", video.volume);
}); });
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove(); document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
@@ -66,6 +150,39 @@
// add to shaka-spacer // add to shaka-spacer
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement; const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
let danmu_enabled = true;
// get danmaku record
let danmu_records: DanmuEntry[] = (await invoke("get_danmu_record", {
roomId: room_id,
liveId: live_id,
platform: platform,
})) as DanmuEntry[];
console.log("danmu loaded:", danmu_records.length);
let ts = parseInt(live_id);
if (platform == "bilibili") {
// history danmaku sender
setInterval(() => {
if (video.paused) {
return;
}
if (danmu_records.length == 0) {
return;
}
// using live source
if (isLive() && get_total() - video.currentTime <= 5) {
return;
}
const cur = Math.floor((video.currentTime + global_offset + ts) * 1000);
let danmus = danmu_records.filter((v) => {
return v.ts >= cur - 1000 && v.ts < cur;
});
danmus.forEach((v) => danmu_handler(v.content));
}, 1000);
if (isLive()) { if (isLive()) {
// add a account select // add a account select
const accountSelect = document.createElement("select"); const accountSelect = document.createElement("select");
@@ -81,6 +198,9 @@
// get accounts from tauri // get accounts from tauri
const account_info = (await invoke("get_accounts")) as AccountInfo; const account_info = (await invoke("get_accounts")) as AccountInfo;
account_info.accounts.forEach((account) => { account_info.accounts.forEach((account) => {
if (account.platform !== "bilibili") {
return;
}
const option = document.createElement("option"); const option = document.createElement("option");
option.value = account.uid.toString(); option.value = account.uid.toString();
option.text = account.name; option.text = account.name;
@@ -90,7 +210,7 @@
const danmakuInput = document.createElement("input"); const danmakuInput = document.createElement("input");
danmakuInput.type = "text"; danmakuInput.type = "text";
danmakuInput.placeholder = "回车发送弹幕"; danmakuInput.placeholder = "回车发送弹幕";
danmakuInput.style.width = "50%"; danmakuInput.style.width = "30%";
danmakuInput.style.height = "30px"; danmakuInput.style.height = "30px";
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)"; danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
danmakuInput.style.color = "white"; danmakuInput.style.color = "white";
@@ -115,7 +235,27 @@
} }
}); });
let danmu_enabled = true; shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
// listen to danmaku event
const unlisten = await listen(
"danmu:" + room_id,
(event: { payload: DanmuEntry }) => {
// add into records
danmu_records.push(event.payload);
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
return;
}
danmu_handler(event.payload.content);
}
);
window.onbeforeunload = () => {
unlisten();
};
}
// create a danmaku toggle button // create a danmaku toggle button
const danmakuToggle = document.createElement("button"); const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启"; danmakuToggle.innerText = "弹幕已开启";
@@ -154,17 +294,12 @@
// Store the positions of the last few danmakus to avoid overlap // Store the positions of the last few danmakus to avoid overlap
const danmakuPositions = []; const danmakuPositions = [];
// listen to danmaku event function danmu_handler(content: string) {
listen("danmu:" + room_id, (event: { payload: string }) => {
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
return;
}
const danmaku = document.createElement("p"); const danmaku = document.createElement("p");
danmaku.style.position = "absolute"; danmaku.style.position = "absolute";
// Calculate a random position for the danmaku // Calculate a random position for the danmaku
let topPosition; let topPosition = 0;
let attempts = 0; let attempts = 0;
do { do {
topPosition = Math.random() * 30; topPosition = Math.random() * 30;
@@ -191,7 +326,8 @@
danmaku.style.margin = "0"; danmaku.style.margin = "0";
danmaku.style.padding = "0"; danmaku.style.padding = "0";
danmaku.style.zIndex = "500"; danmaku.style.zIndex = "500";
danmaku.innerText = event.payload; danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = content;
overlay.appendChild(danmaku); overlay.appendChild(danmaku);
requestAnimationFrame(() => { requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`; danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
@@ -199,10 +335,8 @@
danmaku.addEventListener("transitionend", () => { danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku); overlay.removeChild(danmaku);
}); });
}); }
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
shakaSpacer.appendChild(danmakuToggle); shakaSpacer.appendChild(danmakuToggle);
} }
@@ -234,6 +368,55 @@
shakaSpacer.appendChild(playbackRateSelect); shakaSpacer.appendChild(playbackRateSelect);
let danmu_statistics: { ts: number; count: number }[] = [];
if (platform == "bilibili") {
// create a danmu statistics select into shaka-spacer
let statisticKey = "";
const statisticKeyInput = document.createElement("input");
statisticKeyInput.style.height = "30px";
statisticKeyInput.style.width = "100px";
statisticKeyInput.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
statisticKeyInput.style.color = "white";
statisticKeyInput.style.border = "1px solid gray";
statisticKeyInput.style.padding = "0 10px";
statisticKeyInput.style.boxSizing = "border-box";
statisticKeyInput.style.fontSize = "1em";
statisticKeyInput.style.right = "75px";
statisticKeyInput.placeholder = "弹幕统计过滤";
statisticKeyInput.style.position = "absolute";
function update_statistics() {
let counts = {};
danmu_records.forEach((e) => {
if (statisticKey != "" && !e.content.includes(statisticKey)) {
return;
}
const timeSlot = Math.floor(e.ts / 10000) * 10000; // 将时间戳向下取整到10秒
counts[timeSlot] = (counts[timeSlot] || 0) + 1;
});
danmu_statistics = [];
for (let ts in counts) {
danmu_statistics.push({ ts: parseInt(ts), count: counts[ts] });
}
}
update_statistics();
if (isLive()) {
setInterval(async () => {
update_statistics();
}, 10 * 1000);
}
statisticKeyInput.addEventListener("change", () => {
statisticKey = statisticKeyInput.value;
update_statistics();
});
shakaSpacer.appendChild(statisticKeyInput);
}
// shaka-spacer should be flex-direction: column // shaka-spacer should be flex-direction: column
shakaSpacer.style.flexDirection = "column"; shakaSpacer.style.flexDirection = "column";
@@ -255,6 +438,15 @@
} }
switch (e.key) { switch (e.key) {
case "[": case "[":
e.preventDefault();
start = parseFloat(video.currentTime.toFixed(2));
if (end < start) {
end = get_total();
}
console.log(start, end);
break;
case "【":
e.preventDefault();
start = parseFloat(video.currentTime.toFixed(2)); start = parseFloat(video.currentTime.toFixed(2));
if (end < start) { if (end < start) {
end = get_total(); end = get_total();
@@ -262,6 +454,15 @@
console.log(start, end); console.log(start, end);
break; break;
case "]": case "]":
e.preventDefault();
end = parseFloat(video.currentTime.toFixed(2));
if (start > end) {
start = 0;
}
console.log(start, end);
break;
case "】":
e.preventDefault();
end = parseFloat(video.currentTime.toFixed(2)); end = parseFloat(video.currentTime.toFixed(2));
if (start > end) { if (start > end) {
start = 0; start = 0;
@@ -269,6 +470,7 @@
console.log(start, end); console.log(start, end);
break; break;
case " ": case " ":
e.preventDefault();
if (e.repeat) { if (e.repeat) {
break; break;
} }
@@ -278,22 +480,31 @@
video.pause(); video.pause();
} }
break; break;
case "m": case "p":
e.preventDefault();
if (e.repeat) { if (e.repeat) {
break; break;
} }
video.muted = !video.muted; // dispatch event
dispatch("markerAdd", {
offset: video.currentTime,
realtime: ts + video.currentTime,
});
break; break;
case "ArrowLeft": case "ArrowLeft":
e.preventDefault();
video.currentTime -= 3; video.currentTime -= 3;
break; break;
case "ArrowRight": case "ArrowRight":
e.preventDefault();
video.currentTime += 3; video.currentTime += 3;
break; break;
case "q": case "q":
e.preventDefault();
video.currentTime = start; video.currentTime = start;
break; break;
case "e": case "e":
e.preventDefault();
if (end == 0) { if (end == 0) {
video.currentTime = get_total(); video.currentTime = get_total();
} else { } else {
@@ -301,23 +512,101 @@
} }
break; break;
case "c": case "c":
e.preventDefault();
start = 0; start = 0;
end = 0; end = 0;
break; break;
case "h": case "h":
e.preventDefault();
show_detail = !show_detail; show_detail = !show_detail;
break; break;
} }
}); });
const seekbarContainer = selfSeekbar.querySelector(
".shaka-seek-bar-container.self-defined"
) as HTMLElement;
const statisticGraph = document.createElement(
"canvas"
) as HTMLCanvasElement;
statisticGraph.style.pointerEvents = "none";
statisticGraph.style.position = "absolute";
statisticGraph.style.bottom = "11px";
statisticGraph.style.zIndex = "20";
const canvas = statisticGraph.getContext("2d");
seekbarContainer.appendChild(statisticGraph);
// draw statistics
function drawStatistics(points: { ts: number; count: number }[]) {
if (points == undefined) {
points = [];
}
// preprocess points
let preprocessed = [];
for (let i = 1; i < points.length; i++) {
preprocessed.push(points[i - 1]);
let gap = (points[i].ts - points[i - 1].ts) / 1000;
if (gap > 10) {
// add zero point to fill gap
let cnt = 1;
while (gap > 10) {
preprocessed.push({
ts: points[i - 1].ts + cnt * 10 * 1000,
count: 0,
});
cnt += 1;
gap -= 10;
}
}
}
if (points.length > 0) {
preprocessed.push(points[points.length - 1]);
}
const scale = window.devicePixelRatio || 1;
statisticGraph.width = seekbarContainer.clientWidth * scale;
statisticGraph.height = 30 * scale;
statisticGraph.style.width = `${seekbarContainer.clientWidth}px`;
statisticGraph.style.height = "30px";
const canvasHeight = statisticGraph.height;
const canvasWidth = statisticGraph.width;
// find value range
const minValue = 0;
const maxValue = Math.max(...preprocessed.map((v) => v.count));
const beginTime = player.getPresentationStartTimeAsDate().getTime();
const duration = get_total() * 1000;
canvas.clearRect(0, 0, canvasWidth, canvasHeight);
if (preprocessed.length > 0) {
canvas.beginPath();
const x = ((preprocessed[0].ts - beginTime) / duration) * canvasWidth;
const y =
(1 - (preprocessed[0].count - minValue) / (maxValue - minValue)) *
canvasHeight;
canvas.moveTo(x, y);
for (let i = 0; i < preprocessed.length; i++) {
const x = ((preprocessed[i].ts - beginTime) / duration) * canvasWidth;
const y =
(1 - (preprocessed[i].count - minValue) / (maxValue - minValue)) *
canvasHeight;
canvas.lineTo(x, y);
if (i == preprocessed.length - 1) {
canvas.lineTo(x, canvasHeight);
}
}
canvas.strokeStyle = "rgba(245, 166, 39, 0.5)";
canvas.stroke();
canvas.lineTo(x, canvasHeight);
canvas.closePath();
canvas.fillStyle = "rgba(245, 166, 39, 0.5)";
canvas.fill();
}
}
function updateSeekbar() { function updateSeekbar() {
const total = get_total(); const total = get_total();
const first_point = start / total; const first_point = start / total;
const second_point = end / total; const second_point = end / total;
// set background color for self-defined seekbar between first_point and second_point using linear-gradient // set background color for self-defined seekbar between first_point and second_point using linear-gradient
const seekbarContainer = selfSeekbar.querySelector(
".shaka-seek-bar-container.self-defined"
) as HTMLElement;
seekbarContainer.style.background = `linear-gradient(to right, rgba(255, 255, 255, 0.4) ${ seekbarContainer.style.background = `linear-gradient(to right, rgba(255, 255, 255, 0.4) ${
first_point * 100 first_point * 100
}%, rgb(0, 255, 0) ${first_point * 100}%, rgb(0, 255, 0) ${ }%, rgb(0, 255, 0) ${first_point * 100}%, rgb(0, 255, 0) ${
@@ -327,10 +616,42 @@
}%, rgba(255, 255, 255, 0.4) ${ }%, rgba(255, 255, 255, 0.4) ${
first_point * 100 first_point * 100
}%, rgba(255, 255, 255, 0.2) ${first_point * 100}%)`; }%, rgba(255, 255, 255, 0.2) ${first_point * 100}%)`;
// render markers in shaka-ad-markers
const adMarkers = document.querySelector(
".shaka-ad-markers"
) as HTMLElement;
if (adMarkers) {
// clean previous markers
adMarkers.innerHTML = "";
for (const marker of markers) {
const markerElement = document.createElement("div");
markerElement.style.position = "absolute";
markerElement.style.width = "6px";
markerElement.style.height = "7px";
markerElement.style.backgroundColor = "#93A8AC";
markerElement.style.left = `calc(${(marker.offset / total) * 100}% - 3px)`;
markerElement.style.top = "-12px";
markerElement.style.zIndex = "30";
// little triangle on the bottom
const triangle = document.createElement("div");
triangle.style.width = "0";
triangle.style.height = "0";
triangle.style.borderLeft = "3px solid transparent";
triangle.style.borderRight = "3px solid transparent";
triangle.style.borderTop = "4px solid #93A8AC";
triangle.style.position = "absolute";
triangle.style.top = "7px";
triangle.style.left = "0";
markerElement.appendChild(triangle);
adMarkers.appendChild(markerElement);
}
drawStatistics(danmu_statistics);
}
requestAnimationFrame(updateSeekbar); requestAnimationFrame(updateSeekbar);
} }
requestAnimationFrame(updateSeekbar); requestAnimationFrame(updateSeekbar);
} }
// receive tauri emit // receive tauri emit
document.addEventListener("shaka-ui-loaded", init); document.addEventListener("shaka-ui-loaded", init);
@@ -361,13 +682,46 @@
<p><kbd>]</kbd>设定选区结束</p> <p><kbd>]</kbd>设定选区结束</p>
<p><kbd>q</kbd>跳转到选区开始</p> <p><kbd>q</kbd>跳转到选区开始</p>
<p><kbd>e</kbd>跳转到选区结束</p> <p><kbd>e</kbd>跳转到选区结束</p>
<p><kbd>Alt</kbd><kbd></kbd>前进</p> <p><kbd></kbd>前进</p>
<p><kbd>Alt</kbd><kbd></kbd>后退</p> <p><kbd></kbd>后退</p>
<p><kbd>c</kbd>清除选区</p> <p><kbd>c</kbd>清除选区</p>
<p><kbd>m</kbd>静音</p> <p><kbd>p</kbd>创建标记</p>
</span> </span>
{/if} {/if}
</div> </div>
<div id="shortcuts">
<button
id="shortcut-btn"
on:click={() => {
show_list = !show_list;
}}
>
<GridOutline />
</button>
{#if show_list}
<ul class="shortcut-list">
{#each recorders as recorder}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
class="shortcut"
on:click={() => {
go_to(
recorder.platform,
recorder.room_id,
recorder.current_live_id
);
}}
>
<SortHorizontalOutline />[{recorder.user_info.user_name}]{recorder
.room_info.room_title}
</li>
{/each}
{#if recorders.length == 0}
<p>没有其它正在直播的房间</p>
{/if}
</ul>
{/if}
</div>
<style> <style>
video { video {
@@ -387,7 +741,7 @@
} }
#overlay { #overlay {
position: fixed; position: absolute;
top: 8px; top: 8px;
left: 8px; left: 8px;
border-radius: 6px; border-radius: 6px;
@@ -399,4 +753,45 @@
font-size: 0.8em; font-size: 0.8em;
pointer-events: none; pointer-events: none;
} }
#shortcuts {
position: absolute;
top: 8px;
right: 8px;
flex-direction: column;
display: flex;
align-items: end;
color: white;
font-size: 0.8em;
z-index: 501;
}
#shortcut-btn {
width: 36px;
padding: 8px;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.5);
}
#shortcut-btn:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.shortcut-list {
border-radius: 4px;
padding: 8px;
background-color: rgba(0, 0, 0, 0.5);
}
.shortcut {
display: flex;
flex-direction: row;
cursor: pointer;
}
.shortcut:hover {
text-decoration: underline;
}
</style> </style>

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -8,7 +8,11 @@ const mobile =
process.env.TAURI_PLATFORM === "ios"; process.env.TAURI_PLATFORM === "ios";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
// @ts-ignore
export default defineConfig(async () => ({ export default defineConfig(async () => ({
optimizeDeps: {
exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
},
plugins: [ plugins: [
svelte({ svelte({
preprocess: [ preprocess: [

286
yarn.lock
View File

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